Compare commits

...
74 Commits
Author SHA1 Message Date
YvvandClaude Opus 5.5 f1dbf21723 identité 6 : plus de lectrice derrière la carte du livre sur l'accueil (DA)
ci/woodpecker/push/woodpecker Pipeline was successful
Descendue de 20 px sous la première ligne du chapeau, la lectrice ne montrait plus que sa
bulle. Elle ne peut pas non plus sortir par le côté de la carte, à cause de la marge de page
et de la gouttière, et l'accordéoniste occupe le seul dessus libre. DA, 03/11 après 19h : « Je
ne veux pas d'un oiseau réduit à un morceau ». Elle reste visible à 1440 px sur la page
commande, où elle se tient en marge droite : les 61 oiseaux restent visibles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 19:50:55 +01:00
YvvandClaude Opus 5.5 28a6daf382 identité 6 : la règle f mesurée au-dessus du dessin, les chapitres posés sur leurs filets, les coins arrondis dégagés
Règle f, selon la précision de DA du 03/11 après 17h35 : au-dessus d'un oiseau perché, le vide
se mesure du haut de son dessin jusqu'à la plus basse ligne de texte ou au plus bas bloc qui
passe au-dessus de sa largeur peinte. Les lignes de son propre titre comptent, et l'en-tête
collant aussi en haut de page. Quinze perchés en défaut sur bb8fad9 changent de pose, sans
sortir de la liste fermée :
- accueil :
  - le DJ se tient derrière le pupitre de la console, seule sa tête dépasse ;
  - l'accordéoniste passe en S, visible à partir de 1200 px ;
  - la factrice passe à 76 px ;
  - la lectrice sort 20 px plus bas ;
- au téléphone, l'oiseau de l'intitulé est un oiseau plat : codeuse, maraîchère, potier, et le
  cycliste livreur sur productions collectives ;
- authentification, tarifs de l'eau, commande : en marge droite, sur l'arête haute de la carte ;
- en musique : le danseur passe en S ;
- évènement :
  - l'acrobate se tient avant la devise ;
  - le régisseur passe en S, posé sur l'arête des cartes ;
  - le clown passe en M ;
  - le marionnettiste se tient sur le filet du pied de page (option 1 d'orch). Son décalage
    est tiré de --seal-block, le bloc du sceau défini dans le layout ;
- chapitres : le calligraphe se tient sur le filet du titre, le typographe sur celui de la
  navigation, le cartonnier en marge des boutons. Plus aucune hauteur en %.

Règle g, rectangle du bloc (décision d'orch) : le porteur de serveur et l'administrateur réseau
sortent de la découpe des coins arrondis.

La liste fermée (docs/da-oiseaux.md) reçoit les précisions d'orch :
- e vaut pour un oiseau « libre » dont le centre sort de son bloc ;
- g se mesure sur le rectangle du bloc ;
- un oiseau derrière une carte reste « derrière ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 19:24:44 +01:00
YvvandClaude Opus 5.5 bb8fad9ff3 identité 5 : la liste fermée des règles mesurées, appliquée à toutes les pages
Ordre IDENTITE-DA-5 (03/11 14h41, relayé par la session orch ; seuils confirmés par DA) :
- docs/da-oiseaux.md : « Règles mesurées (liste fermée) », règles a à i avec leurs seuils, telles
  que le contrôle les mesure (pixels peints, ligne de base), et la phrase juste sur l'acrobate ;
- UiOiseauPose : pose `bout`, une ancre sans taille dans le texte, sur sa ligne de base, pour un
  oiseau au bout (ou au début) d'un titre ;
- au bout de leur titre : trapéziste, clown, marionnettiste, DJ (/en-musique), droneuse, vigie,
  alchimiste, relieur, et l'oiseau de l'intitulé au téléphone ;
- authentification : le pompeur de Ğ1 perché sur la carte du projet (plus de colonne en marge) ;
- accueil : l'accordéoniste au bout du titre du format (plus de paire avec la lectrice, plus de
  trait sur le lien) ;
- soudeuse sur la ligne de base de son titre ; administrateur réseau et cartographe sortent par le
  côté de leur carte, pas par un coin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:07:35 +01:00
YvvandClaude Opus 5.5 b6143c5cf7 identité 4 : plus de paire ni de colonne en marge, chaque oiseau sur une ligne visible ; la console arrête un curseur qui descendait
Relecture de fb8c71f..16d6607 (03/11 13h10, relayée par la session orch) :
- /economique : la balance sort de derrière le bloc du livre à mi-hauteur, loin du sol de la
  maraîchère ;
- authentification-wot : la toile de confiance, petite, en marge sur la dernière ligne des étapes
  (le bloc n'a pas de fond) ;
- chapitres : le filet au-dessus de précédent / suivant est tracé (border-t seul n'a pas de style
  ici) ; le cartonnier s'y pose ;
- /evenement : l'acrobate passe au bout de la ligne du leitmotiv, le régisseur reste seul près de
  « Format & lieu » ;
- sommaire du livre : empileur plus loin, illustratrice moins sortie : plus de bande commune.
Trouvés par le contrôle étendu : le débogueur (logiciel-libre) sur la dernière ligne des étapes,
la vigneronne (monnaie-libre) derrière le bloc de texte, qui a un fond.

Console, décision d'orch du 03/11 à 13h10 : au premier geste, un curseur non touché que la
rotation faisait descendre s'arrête là où il est ; un curseur qui montait finit sa montée.
docs/consignes-yvv.md (« Ce que fait le code ») et docs/da-oiseaux.md suivent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 13:52:44 +01:00
YvvandClaude Opus 5.5 16d66072b9 docs : les avis du studio DA et des relectures quittent les consignes d'Yvv (docs/da-oiseaux.md)
consignes-yvv.md ne garde que les mots d'Yvv, datés et mot pour mot. Les deux sections
relayées par la session orch (avis de DA de 07h00, second avis de 07h56 et relecture de
08h14, le 03/11) passent telles quelles dans docs/da-oiseaux.md ; une ligne y renvoie.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 12:38:56 +01:00
YvvandClaude Opus 5.5 8de9a81b65 console : chaque survol rejoue la montée, aucun curseur figé à mi-course ; pied de page sans lien mort
Les quatre écarts de la relecture « 64e714c : ok », graduation d'Yvv inchangée
(0,75, 0,50, 0,667, montée rejouée à chaque survol) :
- premier geste pendant la rotation : un curseur non touché finit en douceur le
  mouvement que la rotation avait commencé (jusqu'à son cran, ou en bas), puis reste.
  La main ne fait descendre aucun curseur (« ne fait pas descendre les autres ») ;
- un nouveau survol, même à moins de 500 ms, rejoue la montée ; seuls la flèche et
  le focus qui la suit, ou le focus et le clic d'un même tap, font une seule montée ;
- un clic sans survol ni focus (lecteur d'écran, script) monte le curseur avec son
  panneau ;
- la flèche part de l'onglet qui a le focus, plus de l'axe sélectionné.

Pied de page : un lien vers une page qui n'existe pas (/mentions-legales) n'est pas
affiché ; il reviendra seul quand la page existera. site.yml n'est pas touché.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 12:34:15 +01:00
YvvandClaude Opus 5.5 44e9576b38 identité 3 : plus de zigzag ni de paires, des tailles variées, chaque oiseau sur une vraie ligne
Second avis du studio DA (03/11, 07h56) et relecture de fb8c71f (08h14) :
- numérique : le zigzag gauche-droite à pas égal est cassé. Électricien en M,
  codeuse et cryptographe au bas de leur carte, à droite, administrateur réseau
  en grand derrière Cloud libre ;
- commande : un seul côté par carte. Cycliste à gauche de la première, libraire
  en grand derrière la seconde ; la lectrice se tient sur le sac de l'icône.
  Le cartonnier va sur la ligne qui clôt chaque chapitre, l'empileur sur le
  sommaire du livre : les 61 restent rendus ;
- au moins deux tailles dès trois oiseaux (chapitre, en-musique, événement) ;
- un oiseau de marge a les pieds sur une vraie ligne, le bas ou le haut de son
  bloc ou d'une carte, jamais à mi-hauteur (« posés sans flotter ») ;
- aucun oiseau sur le haut invisible de la boîte d'un titre : dj, trapéziste,
  alchimiste et droneuse se tiennent sur la dernière ligne ou au bout de
  l'intitulé ;
- téléphone : le pompeur de Ğ1 au bout de la ligne de l'intitulé ;
- sous-pages numériques : poses, tailles et décalages variés malgré le sommaire
  à droite ; le comédien de tarifs-eau perché sur sa carte, hors du sommaire.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 12:33:05 +01:00
YvvandClaude Opus 5.5 fb8c71fc59 identité 2 : un oiseau perché seulement où il y a du vide au-dessus, l'en-tête tient à 768 px
Avis du studio DA du 03/11 (07h00) et relecture de 8ccdda3 :
- règle du perché : il faut au-dessus de l'arête un vide au moins égal à la hauteur
  de l'oiseau, sinon il va en marge ou derrière la carte. Les perchés entre cartes
  rapprochées (numérique, logiciel-libre, économique, sommaire du livre, commande,
  citoyenne, événement) passent en marge, derrière, ou partent ;
- productions-collectives : 8 oiseaux ramenés à 4, sans colonne ni chevauchement.
  Les oiseaux retirés vont sur des pages qui en avaient peu : monnaie-libre
  (boulangère, vigneronne), tarifs-eau (potier), décision (tisserand),
  cloud-libre (imprimeuse 3D). Les 61 restent rendus ;
- pas trois oiseaux sur la même verticale (commande, numérique) ;
- au téléphone, l'oiseau de la page se tient au bout de la ligne de l'intitulé ;
- seuil 1024 pour un perché dont le bloc n'a de vide au-dessus qu'en deux colonnes
  (accordéoniste et factrice de l'accueil) ;
- numerique/[slug] : les oiseaux des sections larges tiennent dans 152 px de marge
  à 1200 px ;
- en-tête : de 768 à 1023 px, boutons resserrés au lieu d'un débordement ; le
  logotype du kit ne change pas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 07:41:10 +01:00
YvvandClaude Opus 5.5 8ccdda39a9 claude.md : les oiseaux et le logotype du studio DA remplacent les shadoks et la signature §
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 06:41:19 +01:00
YvvandClaude Opus 5.5 d2bc0d9e46 identité : le kit du studio DA intégré, les 61 oiseaux posés sur la page
Copie du kit kit-site (empreinte vérifiée) : logotype dans l'en-tête, signe §,
favicons et icônes, og-default (?v=3), frise du pied de page, 61 oiseaux.
Les shadoks positionnés à la main (HomeShadoks et les règles des 10 pages)
cèdent la place à UiOiseauPose : chaque oiseau se pose sur une arête réelle
(perché, marge gauche ou droite, derrière une carte, libre), currentColor,
largeur 72 px au moins, opacité 0,18-0,28 près du texte et 0,10-0,14 derrière,
jamais deux fois sur une page, calligraphe et typographe jamais en miroir.
Sous 768 px, un oiseau par page au plus ; seuls ceux derrière une carte
dérivent un peu, et plus du tout en mouvement réduit.

Consignes d'Yvv du 03/11 inscrites dans docs/consignes-yvv.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 06:39:34 +01:00
YvvandClaude Opus 5.5 cdd3fa57de claude.md : renvoi aux consignes d'Yvv, datées et mot pour mot (docs/consignes-yvv.md)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 06:15:38 +01:00
YvvandClaude Opus 5.5 44dbcfd5cf acte : console de l'accueil — exécuté et vérifié en production (pipeline 60)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 06:15:27 +01:00
YvvandClaude Opus 5.5 26ce315aa6 acte : console de l'accueil, la graduation d'Yvv au survol — dossier « déploie » préparé
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 05:58:48 +01:00
YvvandClaude Opus 5.5 64e714c1dd console : l'animation monte l'axe en avant à son cran
ci/woodpecker/push/woodpecker Pipeline was successful
Avant tout geste, l'axe mis en avant par la rotation monte à sa graduation
(numérique 0,75, économique 0,50, citoyenne 0,667) au lieu de 0,87-0,93 ;
les deux autres gardent leur dérive basse. L'animation et la main donnent
les mêmes niveaux, et un curseur figé au premier geste ne reste plus
au-dessus de son cran. Décision d'orch (03/11, 05h51), consignée dans
docs/consignes-yvv.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 05:56:29 +01:00
YvvandClaude Opus 5.5 03f763ab57 console : graduation d'Yvv, la montée se rejoue à chaque survol
Consigne d'Yvv du 03/11, mot pour mot dans docs/consignes-yvv.md (avec
celles du 29/09) : « Au rollover, numérique monte à 3/4, économie monte à
1/2, citoyenne monte à 2/3. Les curseurs restent. Si on rollover à nouveau
ils refont leur montée. Et le pavé correspondant s'affiche bien sûr au même
rollover. »

- survol à la souris, focus au clavier ou tap : le curseur repart d'en bas
  et monte en 1,1 s à son cran (0,75 / 0,50 / 0,667), puis il y reste ;
  le panneau de l'axe s'affiche au même geste (aria-selected et tabindex
  suivent)
- au premier geste, la rotation s'arrête pour de bon ; les autres
  curseurs restent là où ils sont dessinés, jamais survolés compris
- un défilement au doigt parti d'un curseur ne compte plus comme survol
- flèches du clavier : la montée part avec sa transition rapide
- remplace les choix d'interprétation de 230f79a (curseurs poussés en
  haut, rotation qui reprend, panneau au clic seulement)
- avant tout geste, l'animation du 29/09 est inchangée ; sous
  prefers-reduced-motion, mêmes crans sans transition

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 05:42:32 +01:00
YvvandClaude Opus 5.5 230f79a8c0 console : au survol, le curseur monte sans faire descendre les autres
- survol ou focus d'un curseur : il monte en 0,9 s ; les autres restent
  figés à leur niveau tant que la main est sur la console (dérive et
  rotation suspendues)
- dès le premier geste (survol, focus, clic, flèches), ce qui est monté
  reste monté : le visiteur pousse les curseurs un à un
- avant tout geste, l'animation du 29/09 est inchangée (dérive lente,
  un axe mis en avant à tour de rôle, arrêt au clic)
- prefers-reduced-motion : aucune transition, mêmes règles de niveaux

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 05:17:08 +01:00
YvvandClaude Opus 5.5 89d5f83c7f actes : persistance des messages — preuve au déploiement suivant
Déploiement de 4057f50 (pipeline n° 59) : conteneur recréé, messages.yml
inchangé sur le volume librodrome-data, 0 graine rejouée.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 19:55:03 +01:00
YvvandClaude Opus 5.5 4057f50bd3 actes : persistance des messages — exécuté et vérifié en production
ci/woodpecker/push/woodpecker Pipeline was successful
Section d'exécution du dossier (sorties relevées par orch, session dev-65),
CLAUDE.md corrigé (volume librodrome-data sur /app/data, m4 de la relecture),
HANDOFF : état en ligne et décisions restant à Yvv.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 19:50:34 +01:00
YvvandClaude Opus 5.5 c32d88bec1 actes : persistance des messages — diagnostic et dossier préparé
Diagnostic en lecture seule sur sonic (26/10) : le conteneur tourne
dans /app, les montages visent /src/data et /src/public (dossiers
vides de l'hôte), les messages vivent dans la couche du conteneur et
la graine est rejouée à chaque déploiement ; ADMIN_GIT_SYNC=false,
pas de .git, aucun commit [admin]. Rien perdu depuis le 29/09 ;
avant, invérifiable.

Dossier PRÉPARÉ (mot « déploie ») : sauvegarde et volume semé sur
sonic, veille de l'ancien conteneur, push de a6ba0a6
(fix/persistance-messages), vérification et redémarrage à froid.
Scripts déroulés en entier en local avec le vrai docker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 19:46:41 +01:00
YvvandClaude Opus 5.5 a6ba0a6662 docker : messages persistés dans un volume nommé monté sur /app/data
ci/woodpecker/push/woodpecker Pipeline was successful
Depuis 07bf07a (27/02), l'image tourne dans /app et le serveur lit
process.cwd()/data, mais le compose montait toujours /src/data et
/src/public : les messages vivaient dans la couche du conteneur et
chaque déploiement les remettait à la graine (constaté sur sonic le
26/10, voir actes/2026-10-26-persistance-messages.md).

- volume externe librodrome-data sur /app/data, créé et semé à la main
  avant le déploiement ; sans lui, compose échoue sans toucher au
  conteneur en service ;
- montages morts /src/data et /src/public retirés ;
- docker-compose.dev.yml : volumes en !override, le dev garde data/
  des sources.

Répété en local le 26/10 (node:22, .output local) : échec propre sans
volume, aucune graine rejouée, message posté conservé après
--force-recreate, volume épargné par down -v, retour à l'ancienne
config sans perte du volume.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 17:32:16 +01:00
YvvandClaude Opus 5.5 23b1767e67 claude.md : fiche et boucle de vérification fusionnées
- Rôle, port, dépôt, stack (layer @yvv/nuxt-base, pnpm épinglé, Umami),
  déploiement Woodpecker sans étape de test, graine des messages.
- Section « Vérifier » : V0 (build + smoke rejoués le 26/10/2026), V1 et V2
  à construire, V3, trous connus et gates humains d'anneau 1.
- Constats du 26/10/2026 : montages Docker sur /src alors que l'image tourne
  dans /app, git sync admin inactif (pas de variable, pas de .git, aucun
  commit [admin]). Décision portée au HANDOFF.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 01:28:52 +01:00
YvvandClaude Opus 5.5 852a4881a6 handoff : bloc REPRISE (bascule quota 2026-09-29)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:11:32 +02:00
YvvandClaude Opus 5.5 1c7cc455ab actes : og + favicon — exécuté et vérifié en production
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 09:05:43 +02:00
YvvandClaude Opus 5.5 90f77da49b actes : mise en ligne og + favicon (armé par Yvv)
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 08:52:31 +02:00
YvvandClaude Opus 5.5 74ae8c2666 og : image de partage et favicon avec le vrai §, spaghetti retiré
- og-default.png refaite (§ Playfair, Bricolage, trois autonomies en couleurs)
- favicon.ico : § (c'était le logo Nuxt par défaut)
- JSON-LD : logo pointait vers /images/og-default.png (404) → /og-default.png
- ?v=2 sur l'og:image pour que les réseaux sociaux la reprennent
- retrait des trois tracés « spaghetti » orphelins de public/images

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 08:52:31 +02:00
YvvandClaude Opus 5.5 2144132c74 actes : mise en ligne de la refonte — exécutée et vérifiée en production
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 08:06:49 +02:00
YvvandClaude Opus 5.5 67cd869d83 actes : mise en ligne de la refonte (armé par Yvv)
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 07:44:28 +02:00
YvvandClaude Opus 5.5 201cfbbb50 design : accueil console seul, vrai §, curseurs animés, statuts, lecteur
- accueil : la console devient l'accueil unique (variantes B/C, sélecteur
  et ancien accueil retirés) ; curseurs qui montent de l'asservissement et
  dérivent seuls, un axe mis en avant à tour de rôle, arrêt au clic,
  immobiles en prefers-reduced-motion ; « AUTONOMIE » devant chaque axe,
  leviers mis en valeur, ligne des trois autonomies en couleurs
- logo : § de Playfair Display (SIL OFL) vectorisé, composant partagé
  UiSectionSign (en-tête, page événement) + favicon.svg
- UiStatusBadge (production / développement / gestation) sur l'accueil,
  les pages d'axe et les pages projet
- liens libreDecision / SejeteralO fixés sur la prod (plus de repli localhost)
- logiciel libre : bouton v1 dans la carte projet en tête de page
- couverture du livre : recadrage corrigé (BookSection)
- texte courant en Public Sans (zéros non barrés)
- lecteur : la pastille persistante ne masque plus la navigation du
  BookPlayer ; lecture directe du MP3 (les .ogg n'existent pas : 404)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 05:59:33 +02:00
YvvandClaude Opus 5.5 4d8fe4895d contenus : LaPlanK, sweetHome, statuts de production, sous-titre événement
- LPK → LaPlanK partout ; sweethomeCloud → sweetHome partout
- statuts : Monnaie libre en production depuis 2017 ; wishBounty,
  Décision collective, Tarifs de l'eau en développement ; v1 wishBounty
  en production pour l'écosystème Duniter ; « bridée » retiré
- accueil : « Le librodrome est une invitation », « … une boîte à outils » ;
  échelle de la console Asservissement → Émancipation
- sweetHome clé en main pour chaque collectif, association ou entreprise
  coopérative ; page Le Librodrome : « Un format d'événement »

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 05:59:33 +02:00
YvvandClaude Opus 5.5 e9fce4be8f design : accueil en trois variantes (console, parcours, établi) + couleur par axe
- typo : Bricolage Grotesque (titres) + Atkinson Hyperlegible (texte),
  Syne gardée pour le logo seul ; une seule source de polices (Bunny)
- palettes : teinte propre à chaque axe dans les 4 ambiances ;
  pages d'axe en axis-scope, cartes sans bordure (canon design)
- accueil : console des trois curseurs (tablist clavier), boîte à outils,
  bloc format éditorial + événement + reproduction ; ?v=a|b|c|0
- numérique : carte LPK ; bouquet rendu avec statut et légende
- messages : sans bordure, deux colonnes en grand écran

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:15:46 +02:00
YvvandClaude Opus 5.5 c3ada04e52 contenus : LPK sert l'axe numérique, bouquet réaligné sur les stacks HCO/LPK
- site.yml : bloc lpk (La Plank, asso 1901, financement des communs numériques)
- home.yml : bloc format + une ligne d'engagement par axe
- cloud-libre.yml : bouquet en objets {rôle, logiciel, statut, lien} —
  en service / au catalogue / à venir ; OpenCloud, Twenty, ERPNext,
  Vaultwarden, Woodpecker, Kubo… ; aucune ligne d'origine perdue ;
  équivalents « Gestion & organisation » ajoutés
- evenement.yml : Nextcloud → OpenCloud dans l'axe numérique

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:15:46 +02:00
YvvandClaude Fable 5 38467bea10 build : packageManager épinglé + approbation des scripts de build en CI
ci/woodpecker/push/woodpecker Pipeline was successful
- le pnpm choisi par corepack dans l'image Docker ignorait l'allowlist
  pnpm.onlyBuiltDependencies de package.json (les pnpm 10 récents la
  lisent dans pnpm-workspace.yaml) → ERR_PNPM_IGNORED_BUILDS, exit 1
- packageManager: pnpm@10.29.3 (déterministe, identique au poste local)
- pnpm-workspace.yaml : onlyBuiltDependencies (@parcel/watcher,
  better-sqlite3, esbuild, sharp)
- vérifié : install à froid CI=true --frozen-lockfile exit 0, build zéro
  erreur, prod :3099 → 200

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 02:54:53 +02:00
YvvandClaude Fable 5 da2a8aed49 build : @yvv/nuxt-base en git+https + git dans l'étape build Docker
ci/woodpecker/push/woodpecker Pipeline failed
- la dép git+ssh échouait en CI : pas de clé SSH ni de binaire git dans
  l'étape build (ENOENT sur git clone) — seule la prod installait git
- package.json + pnpm-lock : git+https://git.open.us.org/… (#v0.1.0 →
  b1c6a2f, même commit épinglé) — nécessite le repo yvv-nuxt-base public
- git + ca-certificates ajoutés à l'étape build du Dockerfile
- vérifié : pnpm install + build zéro erreur, prod :3099 → 200

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 01:13:25 +02:00
YvvandClaude Opus 4.8 e64d4cf003 design(sceau): hexagramme 益 traits épais, coins nets, aspect carré
ci/woodpecker/push/woodpecker Pipeline failed
- viewBox 112×105, height 8, rx supprimé (rendu cut)
- géométrie calée sur les modèles IMAGES/PERVIER/Hexagrammes

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 06:54:36 +02:00
aya 483d226c8b empty commit
ci/woodpecker/push/woodpecker Pipeline failed
2026-07-24 23:25:51 +02:00
YvvandClaude Opus 4.8 069d206e94 feat(deps): adopte @yvv/nuxt-base via dépendance git (extends relatif retiré)
ci/woodpecker/manual/woodpecker Pipeline was successful
extends: ['../yvv-nuxt-base'] -> ['@yvv/nuxt-base'] + dépendance
git+ssh://gitea@git.open.us.org/yvv/yvv-nuxt-base.git#v0.1.0.
Rend le projet autonome de l'arborescence locale (débloque Docker/CI).
Build pnpm vert, prod :3099 -> 200, smoke moods OK (vars --color-*
distinctes automne/hiver, classes mood-* appliquées).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 18:15:44 +02:00
YvvandClaude Fable 5 5ec6474b37 feat: adoption layer yvv-nuxt-base — palette.ts délègue à useMood
- extends ../yvv-nuxt-base dans nuxt.config.ts
- application DOM (vars --color-*, palette-light/dark, color-scheme) via useMood(vars) + lightDarkClasses
- storageKey 'palette' et défaut 'ete' conservés — zéro migration utilisateur
- couleurs et API du store inchangées (currentPalette, colors, setPalette, applyToDOM)
- vérifié Playwright : 4 palettes × desktop/mobile au reload + switch à chaud via PaletteSelector, zéro erreur console, prod :3099 → 200
- CONTRIBUTING.md : mécanique déléguée documentée + blocant distribution layer

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 15:14:38 +02:00
YvvandClaude Sonnet 4.6 98fab43fe5 feat: ajout cartouche Ğ1flux dans Productions collectives
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 01:23:49 +02:00
YvvandClaude Sonnet 4.6 a334c97434 fix: Umami externe sur stats.open.us.org — supprime docker-compose.umami.yml
ci/woodpecker/push/woodpecker Pipeline was successful
- host corrigé : stats.open.us.org (instance partagée, non self-hosted)
- docker-compose.umami.yml supprimé (obsolète)
- .env.example simplifié (plus de vars self-hosted)
- README + CONTRIBUTING mis à jour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 04:02:27 +02:00
YvvandClaude Sonnet 4.6 84e843479d feat: intégration nuxt-umami + refonte logo SVG TheHeader
ci/woodpecker/push/woodpecker Pipeline was successful
- nuxt-umami@3.2.1 installé, module configuré (host + website ID)
- suppression injection script manuelle dans app.vue
- TheHeader : logo § repassé en tracé SVG unique avec dégradé primary→accent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 03:39:39 +02:00
YvvandClaude Sonnet 4.6 2f444ea7c7 fix: couverture BookPresentation — object-fit contain, largeur 100%
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 17:57:20 +02:00
YvvandClaude Sonnet 4.6 8408fd6466 feat: SEO complet + analytics Umami + og:image § logo
ci/woodpecker/push/woodpecker Pipeline was successful
SEO :
- composable useSeoPage() : og:*, Twitter Cards, canonical sur toutes les pages (15 pages)
- app.vue : JSON-LD Organization + Book, og:image global og-default.png
- og-default.png 1200×630 : logo § calligraphique + texte (Pillow)
- nuxt.config.ts : @nuxtjs/sitemap avec 26 URLs statiques

Analytics Umami :
- useTracking() : helpers typés audio/pdf/player/scroll/cta
- useScrollTracking() : scroll depth 25/50/75/100% + liens externes auto
- useAudioPlayer : trackAudioPlay/Progress/Complete
- BookPdfReader : trackPdfOpen/Close avec durée
- BookPlayer : trackPlayerOpen/Chapter/Mode
- docker-compose : variables NUXT_PUBLIC_UMAMI_* passées au container

Images :
- Couv-Economie-du-don.jpg ajoutée dans public/images/
- bookplayer.config.yml + home.yml : références mises à jour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 00:25:28 +02:00
YvvandClaude Sonnet 4.6 dcf64cc924 fix: corrections lecteur PDF + couverture + navigation chapitres
ci/woodpecker/push/woodpecker Pipeline was successful
- PDF viewer : suppression animation/lock isAnimating, navigation stable
- PDF reader : focus iframe au chargement → flèches actives immédiatement
- BookSection : couverture via background-image (right center) — fiable
- AxisBlock : boutons secondaires NuxtLink/button explicites (v-if/v-else)
- modele-eco/[slug] : scroll top au changement de chapitre (SPA reuse)
- router.options.ts : scrollBehavior global top/instant
- PDF mis à jour (numéros de pages chapitres 7–11)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 05:12:37 +02:00
YvvandClaude Sonnet 4.6 f6339400fa seed messagerie : message d'embarquement persisté en git
ci/woodpecker/push/woodpecker Pipeline was successful
- site/messages.yml : seed officielle avec le message premier embarquement
- server/plugins/seed-messages.ts : init data/messages.yml au démarrage si absent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 00:33:06 +01:00
YvvandClaude Sonnet 4.6 8fd000a153 numerique/[slug] : bouton v1 bridée sur la page logiciel-libre
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 01:18:41 +01:00
YvvandClaude Sonnet 4.6 95f82e4fee Numérique : blocs cliquables + bouton v1 bridée logiciel libre
ci/woodpecker/push/woodpecker Pipeline was successful
- numerique/index : overlay z-index 1 sur pillar-card, zone actions z-index 2
  avec support pillar.actions (même pattern que citoyenne/index)
- home.yml + numerique.yml : bouton "Voir la v1 bridée (premier flux)"
  sur le bloc Logiciel libre → https://axiom-team.fr/dons
- economique/index : déjà des NuxtLink directs comme cartes, rien à corriger

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 01:01:53 +01:00
YvvandClaude Sonnet 4.6 a038851895 Home/citoyenne : blocs entièrement cliquables + SejeteralO dans tarifs eau
ci/woodpecker/push/woodpecker Pipeline was successful
- AxisBlock : overlay z-index 1 (au-dessus du contenu), actions z-index 2
  (boutons au-dessus de l'overlay) — fix blocage du clic sur tout le bloc
- citoyenne/index : même correction overlay/z-index ; suppression des
  NuxtLink "En savoir plus" devenus redondants (overlay gère la navigation)
- home.yml : ajout bouton "Ouvrir SejeteralO" sur le bloc Tarifs de l'eau

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 00:12:39 +01:00
YvvandClaude Sonnet 4.6 efed0b9033 Page événement : contenu structuré axes/espaces/config depuis PDF Genèse
ci/woodpecker/push/woodpecker Pipeline was successful
- evenement.yml : kicker, titre, subtitle, leitmotiv, tagline, gestation,
  description, 3 axes (numérique/économique/politique), 6 espaces, 4 config
- evenement.vue : hero complet (shadoks, logo SVG inline, badges), sections
  axes/espaces/config, styles scoped responsive
- bookplayer.config.yml → slugs 06-produire/07-echanger déjà commités
- Ajout Librodrome-Logo.png + librodrome-logo.svg (vectorisation en cours)
- Ajout PDF genèse en public/pdf/
- .gitignore, CLAUDE.md, BookSection, économique : ajustements session

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 04:08:00 +01:00
YvvandClaude Sonnet 4.6 99a8b84164 Vouvoiement, libreDecision : bouton + URL
- Vouvoiement partout dans les pages numerique (cloud-libre, logiciel-libre, wot)
- Bouton "Ouvrir libreDecision" dans home AxisBlock (Décision collective)
  - AxisAction supporte href pour liens externes
  - URL prod : decision.librodrome.org
- Labels "Ouvrir libreDecision" (sans Ğ) dans citoyenne/index et [slug]
- app.config.ts : URL prod libredecision mise à jour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 02:37:42 +01:00
YvvandClaude Sonnet 4.6 b9e6b4a96c Messages : types, réponses, sauts de ligne, data volume
ci/woodpecker/push/woodpecker Pipeline was successful
- HomeMessages : type pill (Réaction/Question/Suggestion/Retour) + sélecteur dans le formulaire (sans Réaction)
- HomeMessages : white-space: pre-line sur les messages
- Page /messages : type pill + white-space: pre-line (idem home)
- Admin : badge type coloré + sélecteur d'édition + formulaire réponse
- API : type et reply dans PUT ; readDataYaml/writeDataYaml (data/ volume Docker)
- main.css : overrides light mode text-white/55, /75, /90

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 05:56:11 +01:00
YvvandClaude Sonnet 4.6 c52fa6007d Page /messages : afficher les réponses publiées
ci/woodpecker/push/woodpecker Pipeline was successful
- Type badge + auteur + date comme sur la home
- Réponse avec liaison graphique (reply-thread/connector/block)
- Style adaptatif light/dark cohérent avec HomeMessages.vue

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 05:41:24 +01:00
YvvandClaude Sonnet 4.6 d4ff840e13 bookplayer.config.yml : slugs 06-produire et 07-echanger
ci/woodpecker/push/woodpecker Pipeline was successful
- chapterSongs et chapterPages : 06-economie → 06-produire, 07-echange → 07-echanger
- Les liens "Lire dans le PDF" depuis les chapitres 6 et 7 s'ouvrent maintenant sur les bonnes pages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 00:31:55 +01:00
YvvandClaude Sonnet 4.6 7691cc4139 Chapitres condensés, renommages, numéros de page
- 06-economie → 06-produire (titre "Produire"), 07-echange → 07-echanger
- Frontmatter : champ page: ajouté sur les 11 chapitres (p.9 à p.199)
- content.config.ts : page: z.number().optional() dans le schéma
- modele-eco/index.vue : numéro de page affiché sur chaque carte, hero réduit
- 11 chapitres condensés à ~moitié : voix de l'auteur conservée,
  répétitions et transitions secondaires supprimées, concepts clés préservés

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 00:28:09 +01:00
YvvandClaude Sonnet 4.6 088333e4d4 Monnaie libre : réécriture dans la voix du livre, liens forums, purge co-créer
ci/woodpecker/push/woodpecker Pipeline was successful
- monnaie-libre.yml : texte entièrement réécrit (cavalerie, double symétrie, DU,
  toile de confiance, mesure de gratitude) + 3 nouveaux liens (duniter.org,
  forum.duniter.org, forum.monnaie-libre.fr)
- Suppression de "co-créer/co-créée/co-création" dans tous les fichiers :
  economique.yml, home.yml, authentification-wot.yml, cloud-libre.yml,
  content/book/05-trm.md, content/book/11-annexes.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 22:51:28 +01:00
YvvandClaude Sonnet 4.6 07449de187 Pages détail numérique : sommaire flottant, nav ctx, shadoks geek, contenu enrichi
ci/woodpecker/push/woodpecker Pipeline was successful
- [slug].vue : sommaire sticky (overflow:clip sur parent), prev/next en haut, 6 shadoks geek (pinguin+USB, web-of-trust, rubber-duck, caféine, debugger loupe, rack serveur)
- Nouveaux types de sections : territoire (bouquet sweethomeCloud, 2 modèles éco, tableau matériel dépliable), projet (carte gestation)
- cloud-libre.yml : section sweethomeCloud complète avec infra 50 000 hab. (~2€/an/hab)
- authentification-wot.yml : trustWallet, correction WoT Duniter (Ed25519+Scrypt, sigQty=5, stepMax=3), DID/VC standards
- logiciel-libre.yml : carte projet wishBounty
- home.yml + numerique.yml : cloud-libre → sweethomeCloud, description RGPD/local-first
- AxisBlock.vue : bulles de présentation inline dans les cards (plus de tooltip absolu)
- Analytics : useTracking.ts (Umami), docker-compose.umami.yml, /api/stats fédération
- nuxt.config.ts : config Umami runtime

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 04:40:48 +01:00
YvvandClaude Opus 4.6 9d92c4a5b3 Fix typos blanches admin lightmode + hero audience
ci/woodpecker/push/woodpecker Pipeline was successful
- Remplace color:white → hsl(var(--color-text)) dans tous les composants admin
  (AdminFieldText, AdminFieldTextarea, AdminFormSection, AdminMarkdownEditor,
  AdminMediaBrowser, AdminSidebar, book/index, book/[slug], login, messages, site, songs)
- Conserve color:white uniquement sur fond primary (AdminSaveButton, login-btn)
- Hero home : ajout bloc audience/addressees (clé distincte pour éviter conflit YAML)
- home.yml : réordonne axes (citoyenne en premier — effet triangle)
- TypewriterText : affiche le second bloc avec séparateur fin

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 16:08:03 +01:00
YvvandClaude Opus 4.6 8f548afb17 Palette dynamique dans BookPlayer et admin
ci/woodpecker/push/woodpecker Pipeline was successful
- BookPlayer : toutes les couleurs HSL en dur remplacées par variables CSS palette
- Admin (sidebar, formulaires, pages, book, songs, messages, media, login) : idem
- L'ambiance graphique suit maintenant la palette active partout

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 16:41:23 +01:00
YvvandClaude Opus 4.6 9caf11c8ab Restructuration sections, contenu administrable, shadoks, palette été
ci/woodpecker/push/woodpecker Pipeline was successful
- Structure par section : /numerique, /economique, /citoyenne (plus de /gestation)
- Chaque section a index + sous-pages avec contenu YAML administrable
- API content supporte les chemins imbriqués ([...path])
- Admin : liste des pages + éditeur par section
- Page /economique : monnaie libre (picto Ğ1), modèle éco, productions collectives, commande livre
- Page /citoyenne : decision (CTA Glibredecision), tarifs-eau (CTA SejeteralO)
- BookActions : composant partagé (player, PDF, chapitres, commande) sur home, eco et modele-eco
- GrateWizard remonté dans la section économique de la home
- Palette été par défaut, choix persisté en localStorage
- Fix lisibilité été (text-white/65 + variables CSS)
- Shadoks thématiques sur toutes les pages (8-10 par page, métiers variés)
- Redirections 301 : /gestation/*, /modele-eco/*, /decision, /lire/*
- README, CONTRIBUTING, CLAUDE.md mis à jour

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 16:13:46 +01:00
YvvandClaude Opus 4.6 c564e7be5f GrateWizard bloc dédié, messagerie libre, page numérique 3 piliers
ci/woodpecker/push/woodpecker Pipeline was successful
- GrateWizard : lancement URL simple (plus de popup embed), bloc
  dédié violet sur la home entre axes et événement
- Messagerie : plus de champs obligatoires, plus de champ email
  séparé, hint email dans le message, remerciement onboarding
- Page /numerique : 3 piliers (Logiciel libre, WoT, Cloud libre)
  avec projets associés, remplace les extraits livre hors-sujet
- Admin : carte Messages ajoutée au dashboard
- Safelist icônes complétée

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 03:06:48 +01:00
YvvandClaude Opus 4.6 3a5c40a886 Décision : picto gavel titre + parchemin docs, GrateWizard embed sans double scroll
ci/woodpecker/push/woodpecker Pipeline was successful
- Page décision : icône gavel pour le titre, scroll-text pour Documents de référence
- GrateWizard : ouverture en mode embed (?embed=true&hideTabBar=true) avec
  scrollbars=no pour supprimer le double scroll

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 06:48:52 +01:00
YvvandClaude Opus 4.6 fbc2867163 Refonte accueil : hero typo statique, axes icônes, menu italic, page numérique
- Hero : 5 lignes typographiques alternées (bold/light/accent/caps/italic),
  citations et axes dans un bloc discret dépliable
- Icônes axes : Ğ1 custom, balance (éco don), graphe (WoT), marteau (décision),
  pictos plus lumineux (glow)
- Menu : Autonomie en italique + grand, Événement majuscule
- Page /autonomie renommée /numerique avec redirect 301
- Sceau hexagramme 益 Yì dans le layout, BookSection dans /modele-eco
- Fonts Syne + Space Grotesk, dark theme éclairci
- Popup GrateWizard agrandie (480×860)
- Actions AxisBlock : primary côte à côte, secondary séparé dessous

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 06:34:30 +01:00
YvvandClaude Opus 4.6 082a17d09b Fix accueil : hero fade doux, icônes safelist, blocs cliquables, menu, dark fort
- Hero : réécriture composable timeout pur (plus de Transition callbacks)
  Animation fade opacity 1s très douce, lisible
- Icônes : safelist UnoCSS dans nuxt.config.ts (résout pastilles vides)
- Menu : mis à jour site.yml (Numérique/Économique/Citoyenne/Événement)
- Blocs : card entière cliquable, zone actions séparée (border-top)
- Économie du don : lié à /modele-eco (page chapitres préservée)
- Tarifs de l'eau : bouton SejeteralO (localhost:3009 / collectivites.librodrome.org)
- Dark theme fort : bg 220 12% 15%, surface 19%, surface-light 24%
- Config SejeteralO + Glibredecision dans app.config.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 04:08:47 +01:00
YvvandClaude Opus 4.6 97ba6dd04c Redesign accueil : grille 3 axes, hero fade/swipe, pages gestation et décision
- Hero : animation fade-in/fade-out + swipe (useTypewriter composable + TypewriterText)
- 3 axes : Autonomie numérique, économique, citoyenne (AxisBlock + AxisGrid)
- Pages gestation avec présentations (wishBounty, trustWallet, Cloud libre)
- Page /decision : plateforme Décision collective (lien Glibredecision)
- Bloc événement distinct en bas des axes
- Nav : Numérique / Économique / Citoyenne / Événement
- Dark theme éclairci (bg 7→10%, surface 12→14%)
- Suppression BookSection + GrateWizardTeaser (remplacés par AxisGrid)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 03:49:07 +01:00
YvvandClaude Opus 4.6 f0338cca5e Fix déroulant PDF en production : polyfills DOMMatrix + worker pdfjs
ci/woodpecker/push/woodpecker Pipeline was successful
pdfjs-dist en Node.js pur (hors Vite) nécessite :
- DOMMatrix, Path2D, ImageData polyfills (pas de DOM en Node)
- pdf.worker.mjs copié dans le build (traceInclude dans nitro config)
Testé : 61 entrées retournées en mode production.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:24:59 +01:00
YvvandClaude Opus 4.6 c6b9abf2f3 Fix chemin PDF en production pour l'API pdf-outline
ci/woodpecker/push/woodpecker Pipeline was successful
En prod le PDF est dans .output/public/, pas dans public/.
Cherche dans les deux emplacements (dev et prod).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:55:14 +01:00
YvvandClaude Opus 4.6 df0409fec3 Fix déroulant sommaire PDF : onMounted au lieu de import.meta.client
ci/woodpecker/push/woodpecker Pipeline was successful
Le $fetch dans import.meta.client ne se ré-exécutait pas après hydratation SSR.
onMounted garantit l'exécution côté client après le montage du composant.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:42:58 +01:00
YvvandClaude Opus 4.6 1af00cc64c Admin : déroulant sommaire PDF par chapitre, transitions pages, URL GrateWizard
ci/woodpecker/push/woodpecker Pipeline was successful
- Ajout API /api/admin/pdf-outline (extraction sommaire PDF côté serveur via pdfjs-dist)
- Déroulant <select> dans chaque ligne de chapitre admin avec les 61 titres/sous-titres du PDF
- Sauvegarde des associations chapitre→page PDF via config YAML
- Transition douce (fondu 1s/1.2s) pour le changement de pages dans le viewer PDF
- Correction des numéros de pages réels dans chapterPages (extraits du sommaire PDF)
- URL GrateWizard prod → gratewizard.axiom-team.fr

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:29:54 +01:00
YvvandClaude Opus 4.6 8a38c86794 Fix prod 404 : retrait volumes git sync qui cassent le conteneur
ci/woodpecker/push/woodpecker Pipeline was successful
Les volumes ../site, ../content, ../public montés dans docker-compose
écrasaient les fichiers du conteneur avec des chemins host inexistants.
Retour à la config d'origine. Le git sync sera configuré ultérieurement
quand le serveur aura un clone avec accès push.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:44:44 +01:00
YvvandClaude Opus 4.6 17f39e735d Fix build prod : postinstall tolérant + copy-pdfjs dans Dockerfile
ci/woodpecker/push/woodpecker Pipeline was successful
- postinstall : test -f avant d'appeler copy-pdfjs.sh (absent pendant pnpm install Docker)
- Dockerfile : RUN copy-pdfjs.sh après COPY . . pour copier les fichiers PDF.js
- Dockerfile : COPY content/ en production pour Nuxt Content

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:26:05 +01:00
YvvandClaude Opus 4.6 7ea19e2247 Viewer PDF.js mode livre avec signets, fix hydratation SSR
ci/woodpecker/push/woodpecker Pipeline failed
- Viewer PDF.js mode livre : double page côte à côte, navigation prev/next visuelle et clavier
- Panneau signets (outline) avec tout déplier/replier, highlight du spread courant
- Page 1 = couverture seule, puis paires 2-3, 4-5, etc.
- Navigation clavier : flèches, espace, Home/End
- Redimensionnement auto des canvas au resize
- Fix hydratation SSR : bookData.init() sans await dans ChapterHeader et SongBadges
- BookPdfReader : iframe vers /pdfjs/viewer.html au lieu du viewer natif
- Script postinstall pour copier pdf.min.mjs depuis node_modules

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 18:16:47 +01:00
YvvandClaude Opus 4.6 9525ed3953 Bouton PDF par chapitre, badges morceaux améliorés, PDF configurable admin, git sync admin→prod
ci/woodpecker/push/woodpecker Pipeline was successful
- Bouton PDF blanc par chapitre avec numéro de page (ChapterHeader)
- Badges morceaux plus visibles (bordure, poids, hover) dans ChapterHeader et SongBadges
- PDF viewer : page cible + panneau signets ouverts par défaut (BookPdfReader)
- Config YAML : pdfFile dans book, chapterPages pour le mapping chapitre→page
- Admin book : section PDF du livre avec chemin éditable et sauvegarde
- Git sync automatique : chaque sauvegarde admin commit+push en prod (ADMIN_GIT_SYNC=true)
- Docker : git installé en prod, volumes pour .git/site/content/public

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 15:32:38 +01:00
YvvandClaude Opus 4.6 b02368a15b Fix build prod : Pinia CJS default import crash Node 22+
ci/woodpecker/push/woodpecker Pipeline was successful
Pinia résout vers pinia.prod.cjs en production, Rollup convertit
require('vue') en default import ESM invalide. Script post-build
remplace par un namespace import (* as).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 20:36:37 +01:00
aya 1f47533c77 Merge branch 'develop'
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-27 19:40:43 +01:00
aya 07bf07a942 update app src
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-27 18:38:41 +01:00
233 changed files with 13627 additions and 2163 deletions
+6
View File
@@ -1,3 +1,9 @@
# Admin authentication # Admin authentication
NUXT_ADMIN_PASSWORD=changeme NUXT_ADMIN_PASSWORD=changeme
NUXT_ADMIN_SECRET=change-this-to-a-random-secret-at-least-32-chars NUXT_ADMIN_SECRET=change-this-to-a-random-secret-at-least-32-chars
# Umami analytics — hébergé sur stats.open.us.org
# NUXT_UMAMI_API_KEY : Settings → API Keys dans l'interface Umami (pour /api/stats)
NUXT_PUBLIC_UMAMI_URL=https://stats.open.us.org
NUXT_PUBLIC_UMAMI_WEBSITE_ID=95ff616d-9ce1-47d9-bca2-f6ddc344a99a
NUXT_UMAMI_API_KEY=
+7
View File
@@ -18,9 +18,16 @@ logs
.fleet .fleet
.idea .idea
# PDF.js (copié depuis node_modules par postinstall)
public/pdfjs/pdf.min.mjs
public/pdfjs/pdf.worker.min.mjs
# Sources originales (PDF, JPG — pas servies par l'appli) # Sources originales (PDF, JPG — pas servies par l'appli)
sources/ sources/
# Runtime data (Docker volume — never committed)
data/
# Local env files # Local env files
.env .env
.env.* .env.*
+179 -41
View File
@@ -2,71 +2,209 @@
Site vitrine du projet Le Librodrome — livre + chansons sur l'économie du don. Site vitrine du projet Le Librodrome — livre + chansons sur l'économie du don.
- **Rôle** : site éditorial en production sur `librodrome.org` et hub d'intégration
de l'écosystème (GrateWizard en popup ; liens libreDecision
`decision.librodrome.org`, SejeteralO, sweetHome/LaPlanK dans le YAML de `site/`).
Cap : plateforme éditoriale de bassin de vie (autonomie collective, économie
monnaie libriste, boîte à outils, événementiel).
- **Port** : 3000 (dev), 3099 (smoke du build). **Dépôt** : `origin` =
`gitea@git.open.us.org:EHV/librodrome.git` ; `main` = production.
## Protocole de début de session
1. `git pull --rebase origin main` (règle canon ; voir § Contenu administrable)
2. Vérifier que `data/messages.yml` existe — si absent, signaler avant toute opération
3. Si l'objectif de la session n'est pas précisé, le demander
## Stack ## Stack
- **Nuxt 4** (Vue 3, TypeScript, Nitro) - Nuxt 4 (Vue 3, TypeScript, Nitro) + Nuxt Content, Pinia, UnoCSS, VueUse, Nuxt Image
- **Modules** : Nuxt Content, Pinia, UnoCSS, VueUse, Nuxt Image - Layer `@yvv/nuxt-base` (`extends` dans `nuxt.config.ts`, dépendance `git+https` épinglée `#v0.1.0`)
- **Icônes** : Lucide + Phosphor (via @iconify-json) - Icônes : Lucide + Phosphor (via @iconify-json) ; pnpm épinglé 10.29.3 (`packageManager`)
- **Package manager** : pnpm - Audience : Umami (`nuxt-umami`)
- **Déploiement** : Docker + Traefik, CI via Woodpecker - Déploiement : Woodpecker (`.woodpecker.yml`) — push sur `main` →
`docker compose -f docker/docker-compose.yml -f docker/docker-compose.fabio.yml up --build -d`,
**sans étape de test**. Secrets CI : `NUXT_ADMIN_PASSWORD`, `NUXT_ADMIN_SECRET`.
Healthcheck de l'image : `/api/health`.
## Structure ## Structure
``` ```
app/ app/
pages/ # Routes : index, lire/, ecouter/, autonomie, evenement, gratewizard, a-propos, admin/ pages/
components/ # admin, book, content, home, layout, player, song, ui numerique/ # index + [slug]
composables/ # useAudioPlayer, useBookData, useGrateWizard, usePlaylist, usePageContent, useSiteContent... economique/ # index, monnaie-libre, commande, productions-collectives
assets/css/ # main.css (UnoCSS) modele-eco/ # sommaire + chapitres [slug]
citoyenne/ # index + [slug]
en-musique/ # player audio
admin/ # back-office (pages/, book/, songs, messages, media)
components/
book/Actions.vue # boutons partagés (player, PDF, chapitres, commande)
home/ # BookSection, AxisBlock, AxisGrid, HeroSection, HomeMessages
admin/, player/, song/, ui/
composables/ # useAudioPlayer, useBookData, useGrateWizard, usePageContent
stores/palette.ts # 4 palettes saisonnières (été par défaut, persisté localStorage)
assets/css/main.css # UnoCSS + overrides light mode
site/
pages/ # Contenu YAML administrable par section (sous-dossiers)
site.yml # Config globale (nav, footer, GrateWizard)
bookplayer.config.yml # Config player/chapitres
data/ data/
pages/ # Contenu YAML par page (home, lire, ecouter, autonomie, evenement...) messages.yml # Runtime — prod : volume nommé librodrome-data sur /app/data — JAMAIS dans git
site.yml # Config globale du site
server/ server/
api/ # Endpoints API (admin, health) api/content/ # GET pages YAML (chemins imbriqués)
middleware/ # Auth middleware api/admin/content/ # PUT pages YAML + liste
api/messages/ # GET (publiés) + POST (nouveau message)
api/admin/messages/ # GET tous + PUT (type, reply, published) + DELETE
utils/content.ts # readDataYaml/writeDataYaml (data/) + readYaml/writeYaml (site/)
middleware/redirects.ts # 301 : /gestation, /modele-eco, /decision, /lire
docker/ docker/
Dockerfile # Build multi-stage (dev + prod) Dockerfile, docker-compose.yml, docker-compose.dev.yml
docker-compose.yml # Production (Traefik)
docker-compose.dev.yml # Dev Docker
``` ```
## Ports dev (CRITIQUE) ## Données runtime (CRITIQUE)
Chaque projet a un port fixe pour éviter les conflits Nuxt auto-increment : - `data/messages.yml` : en prod, volume nommé `librodrome-data` (`external: true`) monté
sur `/app/data` (l'image tourne dans `/app`, le serveur lit `process.cwd()/data`).
| Projet | Port | Config | Créé et semé à la main sur sonic par l'acte `actes/2026-10-26-persistance-messages.md` ;
|--------|------|--------| un déploiement sans ce volume échoue au lieu de repartir de la graine.
| **librodrome** | **3000** | `nuxt.config.ts` → `devServer.port: 3000` | - Persisté entre les rebuilds depuis le 26/10/2026 — **jamais écrasé par les commits ni
| **GrateWizard** | **3001** | `package.json` → `next dev --port 3001` | par le déploiement** (avant, il ne l'a jamais été : voir le dossier d'acte)
| **SejeteralO frontend** | **3009** | `frontend/nuxt.config.ts` → `devServer.port: 3009` | - Structure message : `{ id, author, email, text, type, published, createdAt, reply: { text, publishedAt } | null }`
| **SejeteralO backend** | **8000** | Makefile → `uvicorn --port 8000` | - Types : `reaction` (ancien, affiché "Réaction", plus proposé dans les formulaires) | `question` | `suggestion` | `retour`
- En dev local : `<racine>/data/messages.yml`
Script de gestion : `/home/yvv/Documents/PROD/DEV/dev-ports.sh` (status/kill/clean/start) - Graine : `site/messages.yml` (versionné) copié par `server/plugins/seed-messages.ts`
si `data/messages.yml` est absent
**Ne jamais changer ces ports.** Si un port est occupé, tuer le process parasite plutôt que laisser Nuxt auto-incrémenter. - ⚠ Hors volume, rien ne persiste : éditions /admin de `site/` et `content/`, médias
envoyés par /admin (`/app/public`, jamais servis). Décisions d'Yvv en attente (HANDOFF).
- **Avant toute migration de chemin ou écriture sur data/ : demander confirmation**
## Intégration GrateWizard ## Intégration GrateWizard
- URL dev configurée dans `app/app.config.ts` → `localhost:3001` - URL dev : `app/app.config.ts` → `localhost:3001`
- URL prod : `https://gratewizard.ml` - URL prod : `https://gratewizard.axiom-team.fr`
- Ouverture en popup via `composables/useGrateWizard.ts`
- GrateWizard est un projet Next.js séparé (`/home/yvv/Documents/PROD/DEV/GrateWizard`)
## Contenu ## Contenu administrable
Le contenu des pages est dans `data/pages/*.yml` et chargé via `composables/usePageContent.ts`. Le contenu riche (articles) utilise Nuxt Content avec des fichiers Markdown. - YAML dans `site/pages/` par section ; API supporte les chemins imbriqués
- Admin : `/admin/pages` liste, `/admin/pages/{path}` édite en YAML
- Git sync (`server/utils/gitSync.ts`, actif si `ADMIN_GIT_SYNC=true`) : commit
`[admin] …` puis push depuis le conteneur → `git pull --rebase` obligatoire en début de session
- ⚠ Constat du 26/10/2026 : la CI ne fixe pas `ADMIN_GIT_SYNC` (défaut `false` dans le
compose), l'image de prod n'embarque pas `.git`, et l'historique ne contient aucun
commit `[admin]`. Les éditions /admin en prod ne reviennent donc pas dans git
(voir HANDOFF).
## Commandes ## Commandes
```bash ```bash
pnpm dev # Dev server sur :3000 pnpm dev # Dev server :3000
pnpm build # Build production pnpm build # Build production
pnpm generate # Génération statique PORT=3099 node .output/server/index.mjs # Test build prod (toujours avant commit)
``` ```
## Vérifier
Niveaux V0 à V4 : gabarit `harness/skills/boucles/_gabarit.md`. Seuls V0 et V3 existent :
aucun test automatisé, ni lint ni typecheck (aucun script, pas de `vue-tsc`).
**V0 — build + smoke** (vert le 26/10/2026, build ≈ 1 min)
```bash
date; pnpm build # exit 0
NUXT_ADMIN_PASSWORD=jetable NUXT_ADMIN_SECRET=jetable PORT=3099 node .output/server/index.mjs &
sleep 3; for p in / /api/health /api/admin/messages; do
curl -s -o /dev/null -w "$p %{http_code}\n" http://127.0.0.1:3099$p; done # 200, 200, 401
kill $(lsof -ti :3099)
```
Lancé depuis la racine, le serveur lit et écrit le vrai `data/` : aucun POST pendant le
smoke. Le build ne lit pas `.env` : sans `NUXT_ADMIN_SECRET`, l'API admin répond 503.
**V1 — invariants (à construire)** : vitest sur les API messages (POST/GET/DELETE),
admin content (PUT YAML → fichier écrit, format préservé), auth (mauvais mot de passe →
401). `data/` et `site/` se résolvent depuis `process.cwd()` : lancer depuis un dossier jetable.
**V2 — parcours (à construire)** : Playwright, chromium bundlé (HANDOFF, Pièges), mobile +
desktop, ambiances claire et sombre : accueil sans erreur console, les 3 axes, lecteur PDF,
player, formulaire message (data jetable), login admin. Captures dans `/tmp/librodrome-shots/`.
**V3 — données**
```bash
sha256sum data/messages.yml # avant et après le run : identiques
git status --short # rien sous data/ ni .env
git log --oneline origin/main..HEAD # ce que le prochain push mettrait en ligne
```
**V4** : rapport avec sorties réelles et captures ; tout niveau sauté est annoncé.
**Trous** : la CI déploie sans gate (un commit cassé part en prod) ; V1 et V2 inexistants.
Prochaines briques : gate build + tests dans `.woodpecker.yml` avant le `up`, puis V1 et V2
exposés en `pnpm verify`.
**Gates humains (anneau 1)** : push de `main` (= mise en ligne) ; suppressions admin
(`server/api/admin/messages/[id].delete.ts`, `media/[...path].delete.ts`,
`chapters/[slug].delete.ts`) ; toute écriture sous `site/` ou `content/book/` (contenu de
prod) ; `data/messages.yml` ; lien de commande du livre (Bookelis,
`site/pages/economique/commande.yml`, aucun paiement sur le site) ; auth admin
(`server/middleware/admin-auth.ts`) et secrets `NUXT_ADMIN_*`.
## Conventions ## Conventions
- Langue du site : français - **Consignes d'Yvv**, datées et mot pour mot : `docs/consignes-yvv.md`. Les relire avant de
- Commits en français, style concis toucher au design ; y inscrire toute nouvelle consigne le jour où elle est donnée.
- CSS via UnoCSS (utility-first), pas de framework CSS externe - CSS via UnoCSS + variables CSS palettes ; composants Vue SFC `<script setup lang="ts">`
- Composants Vue SFC avec `<script setup lang="ts">` - Oiseaux du kit du studio DA (depuis le 03/11/2026, à la place des shadoks) : `UiOiseau`,
`app/utils/oiseaux.ts` (copiés tels quels du kit), posés par `UiOiseauPose` près du contenu
de leur métier, jamais en rangée. Règles du kit : currentColor, au moins 72 px, opacité
0,18–0,28 près du texte et 0,10–0,14 derrière une carte, un oiseau jamais deux fois sur une
page, un seul sous 768 px, pas de miroir pour calligraphe et typographe. Consignes :
`docs/consignes-yvv.md` § Les oiseaux.
- Hexagramme 益 (#42 Yi, Augmentation) dans `layouts/default.vue`
- Logotype validé du studio DA (`UiLogotype`) dans `TheHeader.vue`, et `UiSectionSign` du
kit — ne pas modifier sans demander. Source du kit :
`/home/yvv/Documents/PROD/IMAGES/_atelier/2026-10-27-librodrome-logo/livrables/kit-site/`.
## SEO & Recherche IA — Checklist permanente
### À chaque nouvelle page Vue
1. **Appeler `useSeoPage()`** — jamais `useHead({ title })` seul
```ts
useSeoPage({
title: 'Titre page — Le Librodrome', // 50–60 chars
description: 'Description...', // 120–155 chars
image: '/images/og-specifique.jpg', // optionnel, sinon og-default.png (logo §)
type: 'website' | 'article' | 'book', // défaut: website
})
```
2. **og:image** : utiliser la couverture `/images/Couv-Economie-du-don.jpg` pour les pages livre/musique ; `og-default.png` (logo §) pour les pages site générales
3. **type: 'article'** pour les chapitres du modele-eco ; **type: 'book'** pour les index livre
### À chaque nouveau fichier YAML dans `site/pages/`
Ajouter le bloc `seo:` en tête :
```yaml
seo:
title: "Titre SEO — 50-60 chars max"
description: "Description 120-155 chars, avec mots-clés naturels"
# image: /images/og-specifique.jpg # optionnel
# keywords: [monnaie libre, TRM, économie du don]
```
### Règles description SEO
- Commence par un verbe d'action ou une affirmation forte
- Contient les mots-clés : économie du don, monnaie libre, TRM, June, bassin de vie, autonomie
- 120–155 caractères (ni trop court, ni tronqué)
- Différente du titre et du H1
### JSON-LD automatique
- JSON-LD `Organization` + `Book` : injecté globalement dans `app.vue` — ne pas redupliquer
- Chapitres → og:type `article` via `useSeoPage({ type: 'article' })`
- Sitemap : géré par `@nuxtjs/sitemap` — ajouter manuellement les nouvelles routes statiques dans `nuxt.config.ts > sitemap.urls`
### Analytics — Nouveaux composants
- Tout bouton CTA externe : `trackCta(label, url)` depuis `useTracking()`
- Tout `<a target="_blank">` : capturé automatiquement par `useScrollTracking()` dans `default.vue`
- Tout nouveau lecteur média : appeler `trackAudioPlay` / `trackPdfOpen` depuis `useTracking()`
+101
View File
@@ -0,0 +1,101 @@
# Contributing — Le Librodrome
## Architecture
```
app/
pages/
numerique/ # Section autonomie numérique
index.vue # Vue d'ensemble (3 piliers)
[slug].vue # Détail pilier (contenu YAML)
economique/ # Section autonomie économique
index.vue # Vue d'ensemble (monnaie libre, livre, productions)
monnaie-libre.vue
commande.vue # Commande du livre (Bookelis + librairie)
productions-collectives.vue
modele-eco/
index.vue # Couverture livre + sommaire chapitres
[slug].vue # Lecteur de chapitre (Nuxt Content)
citoyenne/ # Section autonomie citoyenne
index.vue # Vue d'ensemble (decision, tarifs-eau)
[slug].vue # Détail (contenu YAML)
en-musique/ # Player audio
evenement.vue
admin/ # Back-office protégé
pages/
index.vue # Liste toutes les pages YAML
[...path].vue # Éditeur YAML (supporte les sous-dossiers)
components/
book/Actions.vue # Boutons partagés du livre (player, PDF, chapitres, commande)
home/BookSection.vue # Bloc couverture + CTAs (utilise BookActions)
home/AxisBlock.vue # Bloc axe sur la home (supporte actions lien via `to`)
home/AxisGrid.vue # Grille 3 axes + GrateWizard
stores/palette.ts # 4 palettes saisonnières (été par défaut)
```
## Contenu YAML
API : `GET /api/content/pages/{path}` — supporte les chemins imbriqués (`numerique/logiciel-libre`).
Admin : `PUT /api/admin/content/pages/{path}` + `GET /api/admin/content/pages` (liste).
Les fichiers YAML sont dans `site/pages/`, organisés en sous-dossiers par section.
## Palettes
4 palettes dans `stores/palette.ts` : automne, hiver, printemps, été.
- Défaut : **été** (premier visiteur)
- Persisté en `localStorage('palette')` pour les visites suivantes
- CSS : variables `--color-primary`, `--color-accent`, etc. + classe `.palette-light`/`.palette-dark`
- Mécanique DOM (injection des variables, classes `mood-*`/`palette-*`, `color-scheme`, persistance) déléguée à `useMood` du layer `../yvv-nuxt-base` (`extends` dans `nuxt.config.ts`) — les couleurs restent définies ici. ⚠️ le layer est hors du repo : build Docker/CI cassera tant que la stratégie de distribution n'est pas tranchée (voir README du layer)
- Overrides light mode dans `main.css` (`.palette-light .text-white` → couleur adaptive)
- Admin : tous les `color: white` dans les composants admin sont remplacés par `hsl(var(--color-text))` sauf les boutons sur fond `hsl(var(--color-primary))` (`AdminSaveButton`, `.login-btn`)
## Navigation contextuelle — pages détail
`[slug].vue` (numérique, citoyenne) embarque :
- **Prev/next** (`ctx-nav`) en haut de page — liens vers les pages adjacentes dans la section
- **Sommaire flottant** (`.sommaire-sidebar`, `position: sticky`) dans un aside absolu à droite — visible ≥ 1300px, `overflow: clip` sur le parent pour ne pas casser le sticky
Les ancres sont des `<div :id="\`s${si}\`" class="section-anchor">` avec `scroll-margin-top: 5.5rem` (offset header fixe).
## Types de sections YAML
`[slug].vue` supporte : `arguments`, `fiche`, `equivalents`, `llm`, `atelier`, `links`, `insight`, `tiers`, `projet`, `territoire`.
`type: territoire` : économies alternatives (euro/Ğ1), grille bouquet de services, tableau matériel dépliable (`<details>`).
`type: projet` : carte gestation avec grille de features.
## Shadoks
Illustrations SVG inline sur chaque page, thématiques par section :
- Numérique index : métiers tech/industrie (codeuse, électricien, soudeuse, cryptographe...)
- Numérique détail : postures geek (pinguin+USB, web-of-trust, rubber duck, caféine, rack serveur, debugger loupe)
- Économique : artisanat/agriculture (boulangère, potier, forgeron...)
- Citoyenne : navigation/justice/théâtre (capitaine, avocate, comédien...)
- Modèle éco : livre/édition (typographe, calligraphe, conteuse...)
- Événement : spectacle (funambule, accordéoniste, jongleur...)
Règles : corps compact, longues pattes, grands pieds plats, bec pointu, postures variées.
CSS : `position: absolute`, `pointer-events: none`, `opacity 0.18-0.28`, animation float, `display: none` sur mobile.
Parent avec `overflow: clip` (pas `overflow: hidden` qui casserait `position: sticky`).
## Analytics
`app/composables/useTracking.ts` — wrapper Umami. Module `nuxt-umami` configuré dans `nuxt.config.ts` (host : `stats.open.us.org`).
`server/api/stats/index.get.ts` — endpoint public stats (nécessite `NUXT_UMAMI_API_KEY`).
## Redirections
`server/middleware/redirects.ts` :
- `/gestation/*` → section appropriée
- `/modele-eco/*` → `/economique/modele-eco/*`
- `/decision` → `/citoyenne/decision`
- `/lire/*` → `/economique/modele-eco/*`
- `/ecouter/*` → `/en-musique/*`
## Conventions
- Commits en français, concis
- Build prod vérifié avant push (`PORT=3099 node .output/server/index.mjs`)
- Port dev : 3000 (fixe, jamais de fallback)
+70
View File
@@ -0,0 +1,70 @@
# HANDOFF — librodrome
## ÉTAT — 2026-10-26 vers 19h50
**En ligne** : `4057f50` (code de `a6ba0a6`), les messages des visiteurs sont persistés dans le volume
nommé `librodrome-data` sur `/app/data`. Acte exécuté le 26/10, 19h38-19h44, par la
session dev-65 (orch), et vérifié : sauvegarde, veille, bascule, redémarrage à froid,
1 message conservé, 0 graine rejouée. Dossier : `actes/2026-10-26-persistance-messages.md`.
**Restent à Yvv** (dossier, § Hors champ) :
1. Éditions /admin (`site/`, `content/`), toujours perdues à chaque déploiement :
/admin en lecture seule, git sync rétabli, ou volumes (déconseillé).
2. Médias /admin : écrits dans `/app/public`, jamais servis.
3. Sauvegarde de sonic `~/librodrome-sauvegardes/2026-10-26/` (vraies données de
visiteurs) : la garder, ou la supprimer après un déploiement suivant réussi (« supprime »).
4. Canon démenti : `/DEV/AGENTS.md` (Démarrage §3) et `harness/conventions/git.md`
disent encore que librodrome écrit du YAML en prod via /admin et git sync.
**Preuve en vraie grandeur, faite** : déploiement suivant (`4057f50`, doc seule, pipeline
n° 59, sur le « Déploie » d'Yvv) : conteneur recréé, `messages.yml` inchangé
(`c8afc7de…a6b8`), 0 graine rejouée.
**Local, non poussé** : le commit qui consigne cette preuve (doc seule) ; il part avec
le prochain déploiement.
## REPRISE — bascule quota 2026-09-29 14h05
**Fait, en production** (librodrome.org, déployé et vérifié ; armé par Yvv) :
- `67cd869` : refonte. Accueil = console des trois curseurs, seul accueil
(`app/components/home/HomeLanding.vue`, `HomeConsole.vue`,
`useHomeData.ts`) ; § Playfair (`UiSectionSign`) ; statuts
(`UiStatusBadge`) ; LaPlanK, sweetHome, bouquet aligné sur les stacks
HCO/LaPlanK ; correctifs du lecteur (pastille, MP3 direct).
- `90f77da` : image OG et favicon avec le §, logo JSON-LD corrigé.
- `1c7cc45`, `852a488` et `23b1767` (doc) sont partis avec `a6ba0a6` le 26/10.
Dossiers : `actes/2026-09-29-*.md`.
**Prochaine action** : aucune en cours. Attendre les retours d'Yvv sur la prod.
**Décisions en attente d'Yvv** :
- Qui opère cloud.laplank.org : `vision.md` dit LaPlanK, la fiche sweetHome
dit warren. Le site suit Yvv (LaPlanK).
- Twenty, ERPNext et budgetPilot sont affichés « en service » dans le bouquet
public alors qu'ils servent HCO en interne. Les garder ou passer « au catalogue » ?
- Ambiance par défaut « été » (crème + terre cuite) : proposer une autre ?
- Lien « Mentions légales » du pied de page → page inexistante (défaut antérieur).
- Persistance de la prod (constat du 26/10/2026, d'après les fichiers ; non
vérifié sur le serveur) : voir `CLAUDE.md`, § Données runtime et § Contenu
administrable. Si l'effet se confirme, chaque déploiement repart d'un conteneur
neuf : messages visiteurs re-semés depuis `site/messages.yml`, éditions /admin
perdues. Rétablir les montages (`/app/...`) et décider du sort du git sync
touche aux données de prod : à trancher par Yvv avant le prochain déploiement.
→ **26/10/2026, vérifié sur sonic : confirmé**, puis corrigé et mis en ligne le même
jour (voir ÉTAT ci-dessus).
**Ce qui tourne** : rien (serveur de dev sur 3000 arrêté).
**Pièges** :
- Pousser `main` = mise en ligne sans test → mot « déploie », dossier dans `actes/`.
- `git pull --rebase` avant toute modification (le YAML de prod est édité via /admin).
- Serveur de dev : après des suppressions ou renommages de composants, le SSR
reste périmé (faux mismatch d'hydratation) → relancer `pnpm dev`.
- Chromium de Playwright MCP absent : utiliser le chromium bundlé
`~/.cache/ms-playwright/chromium-1208` + playwright-core
(`/tmp/bp-design/node_modules`). Scripts jetables dans `/tmp/lbshot/`.
- Le BookPlayer ferme sur Échap après une animation de sortie de 0,35 s :
attendre avant d'asserter.
- Statuts : champ YAML `status` (+ `since`), qui prime sur `gestation: true`.
- Libellés de la console dans `home.yml > console` ; titre de la boîte à
outils dans `home.yml > toolbox`.
+60 -63
View File
@@ -1,75 +1,72 @@
# Nuxt Minimal Starter # Le Librodrome
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more. Site vitrine du projet Le Librodrome — livre + chansons sur l'économie du don.
## Setup ## Navigation
Make sure to install dependencies: Le site est organisé en 3 grandes sections :
| Section | Route | Contenu |
|---------|-------|---------|
| **Numérique** | `/numerique` | Autonomie numérique : logiciel libre, authentification WoT, cloud libre |
| **Économique** | `/economique` | Création monétaire, monnaie libre, modèle économique (livre + chapitres), productions collectives |
| **Citoyenne** | `/citoyenne` | Décision collective (Glibredecision), tarifs de l'eau (SejeteralO) |
Autres pages : `/en-musique` (player audio), `/evenement`, `/a-propos`, `/messages`.
Chaque section a une page index et des sous-pages de détail (`/numerique/logiciel-libre`, `/economique/modele-eco`, etc.).
## Le livre
- **Lire** : lecteur PDF intégré, chapitres Markdown sous `/economique/modele-eco/[slug]`
- **Écouter** : player audio avec 9 morceaux sous `/en-musique`
- **Commander** : page `/economique/commande` (Bookelis + librairie)
## Stack
- **Nuxt 4** (Vue 3, TypeScript, Nitro)
- **UnoCSS** (utility-first) + palettes saisonnières (été par défaut)
- **Nuxt Content** pour les chapitres du livre
- **Pinia** pour l'état (palette, player)
- **pnpm** comme package manager
## Contenu administrable
Le contenu des pages est dans `site/pages/` en YAML, organisé par section :
```
site/pages/
home.yml # Page d'accueil
numerique.yml # Index numérique
numerique/*.yml # Sous-pages
economique.yml # Index économique
economique/*.yml # Sous-pages (modele-eco, monnaie-libre, commande...)
citoyenne.yml # Index citoyenne
citoyenne/*.yml # Sous-pages (decision, tarifs-eau)
en-musique.yml, evenement.yml, gratewizard.yml
```
Administration via `/admin/pages` (éditeur YAML, authentifié).
Le hero de la home (`home.yml`) supporte deux blocs dépliables :
- `approach` + `axes` : approche par dimension (numérique → code source, etc.)
- `audience` + `addressees` : à qui s'adresse le projet (collectifs, entreprises, collectivités)
## Développement
```bash ```bash
# npm
npm install
# pnpm
pnpm install pnpm install
pnpm dev # Dev server sur :3000
# yarn pnpm build # Build production
yarn install
# bun
bun install
``` ```
## Development Server Port réservé : **3000** (ne pas changer).
Start the development server on `http://localhost:3000`: ## Analytics
```bash Umami hébergé sur `stats.open.us.org`. Module `nuxt-umami` configuré dans `nuxt.config.ts`.
# npm Stats publiques exposées via `/api/stats` (nécessite `NUXT_UMAMI_API_KEY`).
npm run dev
# pnpm ## Déploiement
pnpm dev
# yarn Docker + Traefik, CI via Woodpecker. Domaine : `librodrome.org`.
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
+32
View File
@@ -0,0 +1,32 @@
# Acte — mise en ligne de l'image de partage et du favicon au vrai §
Classe : déploie (push de main = mise en ligne de librodrome.org)
Projet : librodrome Date : 2026-09-29
Armé le 2026-09-29 par Yvv : « Déploie. […] Vérifie juste l'og. »
## Intention
Remplacer le § « spaghetti » partout où il restait : image OG, favicon,
fichiers orphelins ; corriger l'URL du logo JSON-LD (404).
## Commandes exactes
```bash
git push origin 90f77da49b1c77faf9807ba48740b0abd22aff46:main
```
## Contenu sortant
Commit og (app/app.vue, useSeoPage.ts, public/og-default.png, favicon.ico,
3 fichiers retirés de public/images), le dossier de l'acte précédent (clos)
et ce dossier. Scan data/ .env .db clés : vide.
## Effet attendu
Aperçus de partage et onglet avec le nouveau §. Réversible (git revert).
## Rollback
```bash
git revert --no-edit HEAD~1 && git push origin HEAD:main
```
## Vérifications post-acte
**Exécuté le 2026-09-29** : `67cd869..90f77da -> main`, prod à jour en ~160 s.
- [x] og:image en prod = `https://librodrome.org/og-default.png?v=2` ; JSON-LD logo idem ; fichier servi 200, identique octet pour octet au local
- [x] favicon.ico (identique) et favicon.svg servis 200
@@ -0,0 +1,37 @@
# Acte — mise en ligne de la refonte (accueil console, contenus LaPlanK/sweetHome)
Classe : déploie (le push de main déploie librodrome.org, sans étape de test)
Projet : librodrome Date : 2026-09-29
Armé le 2026-09-29 par Yvv : « Tu déploies directement stp. […] Déploie »
## Intention
Mettre en ligne la refonte : accueil « console des curseurs », vrai §, statuts
de production, bouquet sweetHome aligné sur les stacks réelles, LaPlanK,
correctifs lecteur (pastille, MP3 direct) et couverture du livre.
## Commandes exactes
```bash
git push origin 67cd869d833bcb7924c6458f4d4b1928bb59e62a:main
```
## Contenu sortant
5 commits (c3ada04, e9fce4b, 4d8fe48, 201cfbb + ce dossier) : app/, site/
(YAML de contenu), nuxt.config.ts, uno.config.ts, public/favicon.svg.
Scan `data/ .env .db .enc clés IDENTIFIANTS` sur `git diff origin/main..HEAD` : **vide**.
`git pull --rebase origin main` juste avant : à jour (aucune édition admin
en prod à intégrer).
## Effet attendu
librodrome.org reconstruit par Woodpecker. Irréversibilité : réversible.
## Rollback
```bash
git revert --no-edit c3ada04^..HEAD && git push origin HEAD:main
```
(ou redéployer 38467be)
## Vérifications post-acte
**Exécuté le 2026-09-29** : `38467be..67cd869 -> main`.
- [x] Nouveau bundle servi ~60 s après le push (`entry.CIXHEUkt` → `entry.D1w8lqIL`)
- [x] 17 pages × 2 ambiances (été, hiver) en prod : 0 erreur console, 0 hydratation, 0 débordement ; captures accueil desktop + mobile conformes
- [x] BookPlayer en prod : lecture MP3 (200), suivant, sommaire, fermeture, Échap ; PDF 200 ; /api/messages 200
+281
View File
@@ -0,0 +1,281 @@
# Acte — persistance des messages de librodrome.org (volume nommé sur /app/data)
Classe : déploie (un volume créé et semé sur sonic, puis le push de `main`, qui met en ligne sans étape de test)
Projet : librodrome Date : 2026-10-26
**État : EXÉCUTÉ** le 26/10/2026, 19h38-19h44, par la session dev-65 (orch, sous
mandat de rôle, relecture indépendante inscrite) ; préparé vers 18h par la session de
diagnostic. Section d'exécution en fin de dossier.
## Constat (diagnostic du 26/10/2026, en lecture seule)
| Fait | Preuve (26/10/2026, sauf date indiquée) |
|---|---|
| La prod tourne sur sonic | `getent ahostsv4 librodrome.org` → 161.97.174.60 (sonic, `harness/conventions/infra.md`) |
| Un seul conteneur : `lpk-librodrome-main-app-1`, image `lpk-librodrome-main-app` (`sha256:8fe50a5b7c65…`), créé le 2026-09-29T06:59:03Z | `docker ps -a`, `docker inspect` |
| Répertoire de travail `/app` ; le serveur lit et écrit `process.cwd()/data`, donc `/app/data` | `docker inspect` (`WorkingDir=/app`) ; `server/utils/content.ts`, `server/plugins/seed-messages.ts` |
| Montages : deux binds seulement, `…/LPK/librodrome/data → /src/data` et `…/LPK/librodrome/docker/public → /src/public`, aucun volume nommé | `docker inspect` (`.Mounts`, `.HostConfig.Binds`) |
| Les sources de ces binds sont des dossiers vides de root sous `/woodpecker/src/git.open.us.org/LPK/librodrome/`, créés par le démon le 2026-08-12 à 00h58m36Z, la seconde où s'est terminé le pipeline #55. L'ancien chemin `EHV/librodrome/` est vide lui aussi (créé le 23/03 à 03h10m44Z, fin du #36 à 03h10m45Z). | `ls -la --time-style=full-iso`, `find` sur sonic ; API CI. Mécanisme : la CI lance compose depuis son espace de travail, et un chemin relatif y désigne un chemin de l'hôte qui n'existe pas (déjà noté en 8a38c86, 28/02). |
| `/app/data/messages.yml` vit dans la couche du conteneur, et non dans un montage : 1099 octets, daté du 2026-09-29T06:59:05Z, sha256 `c8afc7de…a6b8`, identique à la graine `site/messages.yml`, avec 1 message (celui d'Yvv) | `docker exec … sha256sum`, `stat`, `grep -c` ; `docker diff` → `A /app/data/messages.yml` |
| Graine rejouée au premier démarrage du conteneur actuel | `docker logs` : `2026-09-29T06:59:05Z [seed-messages] data/messages.yml initialisé depuis site/messages.yml`, une seule fois. Les redémarrages des 12/10 et 18/10 (×4) ne l'ont pas rejouée : un redémarrage garde la couche, une recréation (un déploiement) la détruit. |
| Aucune édition /admin dans le conteneur actuel | `docker diff` : rien sous `/app/site` ni `/app/content` |
| `ADMIN_GIT_SYNC=false`, pas de `/app/.git`, et aucun commit `[admin]` dans l'historique | `docker inspect` (variable non secrète) ; `ls -d /app/.git` → absent ; `git log --all --grep='^\[admin\]'` → 0 |
| Les secrets `NUXT_ADMIN_PASSWORD` et `NUXT_ADMIN_SECRET` sont définis ; les variables Umami sont vides | `docker inspect`, longueur seulement (aucune valeur relevée) |
| 42 déploiements réussis sur `main` du 25/02 au 29/09 (#4 à #57) ; les derniers : #46 le 24/04, #50 le 24/07 (manuel, 10 s : recréation non vérifiée), #55 le 12/08, #56 et #57 le 29/09 | `https://ci.open.us.org/api/repos/1/pipelines` (lecture anonyme, Woodpecker 3.19.0) |
**Diagnostic** : la persistance de la prod est cassée, et elle l'a toujours été.
Avant le 19/03 (`b9e6b4a`), les messages s'écrivaient dans `site/messages.yml`, dans
le conteneur ; depuis, dans `/app/data`, dans le conteneur aussi. Chaque déploiement
a donc remis les messages à zéro, puis à la graine à partir du 28/03 (`f633940`).
Les éditions /admin de `site/` et `content/` ont subi le même sort, et ne sont jamais
revenues dans git.
**Pertes** : depuis le 29/09, rien n'a été perdu (aucun message reçu, aucune édition).
Avant cette date, ce qui a été perdu n'est pas vérifiable : les conteneurs détruits
emportent leur couche, et les journaux n'en gardent pas trace (le journal de
l'application commence le 29/09, celui de fabio le 07/10).
## Intention
Monter les messages des visiteurs sur un volume nommé `librodrome-data` en
`/app/data`, pour qu'ils survivent aux déploiements. Il faut le faire maintenant :
au prochain push, tout message reçu d'ici là serait perdu. Le code est prêt sur la
branche locale `fix/persistance-messages` (a6ba0a6), et il a été répété en local.
## Avant — prémisses vivantes
Sur le poste, dans `/home/yvv/Documents/PROD/DEV/librodrome` (attendu en commentaire) :
```bash
git status --short # P1 : vide
git fetch -q origin && git rev-parse origin/main # P2 : 90f77da49b1c77faf9807ba48740b0abd22aff46
# sinon ARRÊT : quelqu'un a poussé (pull --rebase, rebaser la branche, refaire le dossier)
git rev-parse fix/persistance-messages # P3 : a6ba0a6662d54e6ed42ef26ce9e1411e873a2484
git merge-base --is-ancestor origin/main a6ba0a6662d54e6ed42ef26ce9e1411e873a2484; echo $? # P4 : 0
git diff --name-only origin/main a6ba0a6662d54e6ed42ef26ce9e1411e873a2484 \
| grep -E '(^|/)data/|\.env|\.db$|\.enc$|\.key$|\.pem$|id_rsa|id_ed25519|IDENTIFIANTS'; echo $? # P5 : 1 (rien)
curl -4 -s 'https://ci.open.us.org/api/repos/1/pipelines?perPage=3' \
| python3 -I -c 'import sys,json; [print(p["number"], p["status"]) for p in json.load(sys.stdin)]' # P6 : « 57 success » en tête, rien en running ni pending
curl -4 -s -o /dev/null -w '%{http_code}\n' https://librodrome.org/api/health # P7 : 200 (server/api/health.ts ne fait que répondre)
curl -4 -s https://librodrome.org/api/messages \
| python3 -I -c 'import sys,json; print(len(json.load(sys.stdin)))' # P8 : 1 au 26/10 (lecture seule : server/api/messages/index.get.ts)
```
Pour P8, un autre nombre veut dire que des messages ont été publiés depuis : le
noter, il doit se retrouver après.
Sur sonic, les prémisses sont vérifiées par le script de l'étape 1, qui s'arrête en
les nommant : conteneur en marche, `WorkingDir=/app`, `/app/data` non monté, volume
`librodrome-data` absent, dossier de sauvegarde absent, `docker diff` vide sous
`/app/site` et `/app/content`. Une édition /admin trouvée arrête l'acte : elle doit
d'abord passer dans git, sinon ce déploiement l'effacerait.
Contenu sortant (`git log origin/main..a6ba0a6 --stat`, relevé le 26/10) :
```
a6ba0a6 2026-10-26 docker : messages persistés dans un volume nommé monté sur /app/data
docker/docker-compose.dev.yml | 3 ++-
docker/docker-compose.yml | 16 ++++++++++++++--
23b1767 2026-10-26 claude.md : fiche et boucle de vérification fusionnées
CLAUDE.md | 76 +++… HANDOFF.md | 11 +++…
852a488 2026-09-29 handoff : bloc REPRISE (bascule quota 2026-09-29)
HANDOFF.md | 39 +++…
1c7cc45 2026-09-29 actes : og + favicon — exécuté et vérifié en production
actes/2026-09-29-og-logo-mise-en-ligne.md | 8 +++++---
```
Le diff du compose retire deux lignes, les montages morts `../data:/src/data` et
`./public:/src/public`, et en ajoute une, `data:/app/data`, plus le bloc `volumes:`
(`librodrome-data`, `external: true`). Ce dossier n'est pas dans le push : il est
sur `main` en local, et partira avec le déploiement suivant.
## Commandes exactes
Depuis le poste, dans `/home/yvv/Documents/PROD/DEV/librodrome`. Les scripts sont
dans `actes/annexes/`, et chacun se retire de `/tmp` à la fin.
```bash
# Étape 1 — sauvegarde, puis volume créé et semé (sonic ; le conteneur en service n'est pas touché)
scp -q -o BatchMode=yes actes/annexes/2026-10-26-persistance-messages-avant.sh yvv@161.97.174.60:/tmp/lb-avant.sh \
&& scp -q -o BatchMode=yes actes/annexes/2026-10-26-persistance-messages-veille.sh yvv@161.97.174.60:/tmp/lb-veille.sh \
&& scp -q -o BatchMode=yes actes/annexes/2026-10-26-persistance-messages-apres.sh yvv@161.97.174.60:/tmp/lb-apres.sh \
&& ssh -o BatchMode=yes yvv@161.97.174.60 'bash /tmp/lb-avant.sh' </dev/null
# attendu : « sauvegarde : N message(s), sha256 … », « volume librodrome-data semé : sha256 … » (même empreinte), « ÉTAPE 1 OK »
# Étape 2 — veille sur l'ancien conteneur, jusqu'à sa disparition (30 min au plus)
ssh -o BatchMode=yes yvv@161.97.174.60 'setsid nohup bash /tmp/lb-veille.sh >/tmp/lb-veille.log 2>&1 &' </dev/null
# Étape 3 — mise en ligne (push du SHA vérifié), puis attente du pipeline
for i in 1 2 3 4 5; do if git push -q origin a6ba0a6662d54e6ed42ef26ce9e1411e873a2484:main; then break; fi; sleep 10; done; git status -sb | head -1
for i in $(seq 1 40); do s=$(curl -4 -s -m 20 'https://ci.open.us.org/api/repos/1/pipelines?perPage=5' | python3 -I -c 'import sys,json; print(next((p["status"] for p in json.load(sys.stdin) if p["commit"].startswith("a6ba0a6662d5")), "absent"))'); echo "$(date +%T) $s"; case "$s" in success|failure|error|killed|declined) break;; esac; sleep 15; done
# attendu : « success » en 3 min environ (#56 : 3 min 18 s, #57 : 2 min 36 s)
# Étape 4 — vérification sur sonic : bascule, veille, redémarrage à froid
ssh -o BatchMode=yes yvv@161.97.174.60 'cat /tmp/lb-veille.log; bash /tmp/lb-apres.sh && rm -f /tmp/lb-veille.log' </dev/null
# attendu : « ancien conteneur remplacé », « veille : dernière relève = sauvegarde », deux fois « fichier du volume = sauvegarde », « ÉTAPE 4 OK »
# Étape 5 — vu de l'extérieur
curl -4 -s -o /dev/null -w '%{http_code}\n' https://librodrome.org/api/health
curl -4 -s -o /dev/null -w '%{http_code}\n' https://librodrome.org/
curl -4 -s https://librodrome.org/api/messages | python3 -I -c 'import sys,json; print(len(json.load(sys.stdin)))'
# attendu : 200, 200, le nombre relevé en P8
# Étape 6 — branche locale sur la tête déployée, puis compte rendu
git switch main && git rebase a6ba0a6662d54e6ed42ef26ce9e1411e873a2484
```
Si le pipeline échoue à l'étape 3 (par exemple sur `external volume "librodrome-data"
not found`), l'ancien conteneur continue de servir, sans changement : la répétition
l'a montré. La veille s'arrête seule au bout de 30 min. On ne relance rien : on
rapporte.
## Effet attendu
- librodrome.org est reconstruit par Woodpecker. `/app/data` devient le volume
`librodrome-data`, et les messages survivent désormais aux déploiements, aux
redémarrages et à `docker compose down -v` (volume `external`).
- Les montages morts `/src/data` et `/src/public` disparaissent. Les dossiers vides
de root sous `/woodpecker/src/git.open.us.org/{EHV,LPK}/librodrome/` restent sur
l'hôte, sans effet ; les retirer demande root (hors acte).
- Ce qui ne change pas : les éditions /admin de `site/` et `content/` restent perdues
à chaque déploiement, les médias envoyés par /admin ne sont toujours pas servis, et
`ADMIN_GIT_SYNC` reste à `false` (décisions d'Yvv, voir plus bas).
- Fenêtre : entre la sauvegarde et la bascule (3 min environ), un message posté va
dans l'ancien conteneur. La veille le relève toutes les 5 s, et l'étape 4 s'arrête
s'il y en a un. Le risque résiduel tient en quelques secondes : la dernière relève,
puis l'arrêt du conteneur. Le débit observé est de 0 message en 27 jours.
- Irréversibilité : réversible (voir Retour). Ni l'acte ni son retour ne suppriment le
volume ni la sauvegarde.
## Vérifications après
- [x] P7 et P8 rejoués : `200`, et le même nombre de messages publiés
- [x] `lb-apres.sh` : `/app/data` monté sur le volume `librodrome-data`, aucun autre montage, 0 ligne `seed-messages`, fichier du volume = sauvegarde, idem après `docker restart`
- [x] La veille a vu partir l'ancien conteneur, et sa dernière relève est identique à la sauvegarde
- [x] Pipeline du commit `a6ba0a6662d5` : `success`
- [x] Sauvegarde présente sur sonic : `~/librodrome-sauvegardes/2026-10-26/` (700, fichiers 600 ; `messages.yml`, `SHA256SUMS`, `ancien-conteneur.id`, relève de la veille)
- [x] Au déploiement suivant, quel qu'il soit : `docker exec lpk-librodrome-main-app-1 sha256sum /app/data/messages.yml` inchangé, et aucune ligne `seed-messages`. C'est la preuve en vraie grandeur.
## Retour
Retour du code, par un nouvel acte « déploie » :
```bash
git switch main && git revert --no-edit a6ba0a6662d54e6ed42ef26ce9e1411e873a2484 \
&& for i in 1 2 3 4 5; do if git push -q origin "$(git rev-parse HEAD)":main; then break; fi; sleep 10; done
```
Effet du retour, répété en local le 26/10 : le conteneur est recréé sans le volume
et re-semé depuis la graine. Le volume `librodrome-data` reste intact, avec tous les
messages reçus depuis la bascule ; redéployer le correctif les remonte.
Arrêt après l'étape 1, avant le push : rien à défaire en production, puisque le
conteneur n'a pas changé. Le volume et la sauvegarde restent ; un second passage de
l'étape 1 s'arrêtera sur « le volume existe déjà », ce qui est voulu. Les retirer est
un acte « supprime » à part.
La sauvegarde de sonic contient de vraies données de visiteurs (auteurs, adresses).
Elle reste sur sonic, et ne quitte pas le serveur, jusqu'à ce qu'Yvv décide ; sa
suppression est un acte « supprime ».
## Répétition (26/10/2026, sur le poste)
Faite avec le vrai docker (29.4.1, compose v5.1.3) et les noms exacts de la prod :
projet `lpk-librodrome-main`, conteneur `lpk-librodrome-main-app-1`, image locale
`lpk-librodrome-main-app` (`node:22-bookworm-slim` plus le `.output`, `site/` et
`content/` du dépôt, en couche d'image comme en prod). L'ancien conteneur venait de
l'ancien compose (`main`), la bascule du compose d'a6ba0a6, et `HOME` pointait sur un
faux dossier.
- Nominal : étape 1 OK (sauvegarde, puis volume semé à la même empreinte), veille
arrêtée à la bascule, étape 4 OK avant et après le redémarrage à froid, 0 graine
rejouée.
- Nouveau compose sans volume : `external volume "librodrome-data" not found`,
sortie 1, ancien conteneur intact (même identifiant).
- Étape 1 rejouée après la bascule : ARRÊT « /app/data est déjà monté ».
- Édition /admin présente dans la couche : ARRÊT, aucun volume créé.
- Message posté dans l'ancien conteneur après la sauvegarde : relevé par la veille
(2 messages), étape 4 en ARRÊT avec la consigne.
- Message posté après la bascule, puis `--force-recreate` : conservé, sans graine ;
le volume survit à `down -v` ; le retour à l'ancien compose re-sème sans toucher au
volume.
- Second déroulé, après correction de la veille (elle relevait par nom, et le nom
désigne le nouveau conteneur dès la bascule ; elle relève maintenant par
identifiant, et laisse `veille.fin`), à 16h58-16h59Z :
- étape 4 lancée avant le déploiement : ARRÊT « conteneur non recréé » ;
- message posté après la bascule : la dernière relève reste égale à la
sauvegarde, l'étape 4 passe avec la NOTE « fichier du volume changé (2
messages) », avant et après le redémarrage ;
- message tardif dans l'ancien conteneur : ARRÊT, comme au premier déroulé.
- Non déroulés : la sortie de la veille au bout de 30 min sans bascule, et
l'arrêt « la veille n'a pas constaté la bascule » (fenêtre de moins de 5 s
entre la recréation et la fin de la veille).
- Répétition retirée du poste : conteneur, volume et image locale supprimés, ainsi
que `/tmp/lbacte` (rien que du factice et une copie du build).
Différences avec la prod : node 22 au lieu de 23.11 (sans effet sur les montages ni
sur la graine), compose du poste au lieu de celui de l'image `docker:dind` de la CI,
et pas de proxy fabio devant le conteneur.
## Hors champ — décisions d'Yvv
1. **Éditions /admin** (`site/`, `content/`) : perdues à chaque déploiement, jamais
remontées dans git. Trois voies : (a) /admin en lecture seule en prod, et le
contenu ne s'édite plus que par git ; (b) rétablir le git sync : un clone avec
droit d'écriture sur `LPK/librodrome` (acte « accorde »), `.git` monté,
`ADMIN_GIT_SYNC=true` en CI, et chaque édition devient un push, donc un
déploiement ; (c) des volumes sur `site/` et `content/`, ce qui fait deux
vérités face à git (déconseillé).
2. **Médias /admin** : `upload.post.ts` écrit dans `/app/public`, qui n'existe pas
dans l'image, et Nitro ne sert que le manifeste figé à la construction
(`.output/server/chunks/nitro/nitro.mjs`, `readAsset` et `assets[id]`). Un envoi
n'est donc jamais servi. `/admin/media` lit `/app/public` : déduit du code, il
doit répondre 500 (non sondé).
3. **Canon démenti** : `/DEV/AGENTS.md` (Démarrage §3) et
`harness/conventions/git.md` (« Début de session », point 1) disent que librodrome
écrit du YAML en prod via /admin et git sync. C'est faux depuis l'origine
(preuves ci-dessus). Même chose pour `CLAUDE.md` du dépôt, § Données runtime :
« Persisté entre les rebuilds — jamais écrasé ».
4. **Sauvegarde sur sonic** : la garder, ou la supprimer après un déploiement
suivant réussi (acte « supprime »).
## Exécution
Acte exécuté le 26/10/2026, 19h38-19h44, par la session dev-65 (orch, mandat de rôle
du 26-27/10). Relecture indépendante de a6ba0a6 et des scripts : un sous-agent neuf,
verdict « ok » à 18h48, inscrite dans `state/relectures.json`. Sorties relevées par
orch, puis consignées par la session librodrome-e9.
- **Prémisses** (19:38:45) : P1 vide, P2 `90f77da49b1c…`, P3 `a6ba0a6662d5…`, P4 `0`,
P5 `1`, P6 `57 success` / `56 success` / `55 success`, P7 `200`, P8 `1`.
Contrôle ajouté (relecture, m1) : `docker diff` du conteneur, 0 entrée sous
`/app/(public|site|content)`.
- **Étape 1** (19:39:43-19:39:51) : « sauvegarde : 1 message(s), sha256
c8afc7de728fb9d57f6565a36b40f16fe7beb438768f8f9127183bea9569a6b8, 1099 2026-09-29
06:59:05.542963509 +0000 » ; « volume librodrome-data semé : sha256 c8afc7de…a6b8 » ;
« ÉTAPE 1 OK ».
- **Étape 2** : veille lancée vers 19h40.
- **Étape 3** : boucle de push lancée seule. Écart au déroulé : sans le
`; git status -sb | head -1` final, que le garde refuse sous mandat. `origin/main` =
`a6ba0a6662d54e6ed42ef26ce9e1411e873a2484`. Pipeline n° 58 : running à 19:42:41,
success à 19:43:51.
- **Étape 4** (jusqu'à 19:44:14) : « ancien conteneur remplacé, constaté à
2026-10-26T18:43:46Z » ; « veille : dernière relève = sauvegarde
(2026-10-26T18:43:41Z) » ; bascule, puis redémarrage à froid, deux fois « fichier du
volume = sauvegarde (1 message(s)) » ; « ÉTAPE 4 OK ».
Contrôle indépendant (relecture, m2) : journal du conteneur lu (2 lignes), 0 ligne
`seed-messages` ; seul montage, le volume `librodrome-data` sur `/app/data` ; conteneur
créé le 2026-10-26T18:43:43Z.
- **Sauvegarde** sur sonic : `~/librodrome-sauvegardes/2026-10-26/` (700, fichiers 600 :
`SHA256SUMS`, `ancien-conteneur.id`, `derniere-releve.date`,
`messages-derniere-releve.yml`, `messages.stat`, `messages.yml`, `veille.fin`).
`/tmp/lb-*` retirés, aucune veille en cours.
- **Étape 5** (19:44:41) : health `200`, accueil `200`, messages `1`.
- **Étape 6** : `main` locale rebasée sur `a6ba0a6` sans conflit ; ce dossier (`52f0a00`)
devient `c32d88b`.
Mineurs de la relecture, non bloquants, à reprendre si l'on réutilise ces scripts :
m1 `avant.sh` ne regarde pas `/app/public`, où /admin écrit ses médias (paré à la
main, ci-dessus) ; m2 `|| true` sur `docker logs` dans `apres.sh` (paré par le contrôle
indépendant) ; m3 pas de témoin de fin d'étape 1 ; m4 `CLAUDE.md` décrivait encore
`../data:/src/data` (corrigé dans ce commit) ; m5 les statuts `blocked` et `skipped` ne
sont pas prévus par la boucle de l'étape 3 ; m6 le dossier de sauvegarde est à retirer
à la main si l'étape 1 échoue.
**Preuve en vraie grandeur** : faite au déploiement suivant, par la session
librodrome-e9, sur le « Déploie » d'Yvv tapé dans cette session (26/10, vers 19h50 :
« il bosse, je le court-circuite. Déploie […] »). Push de `4057f50` (doc seule) ; le
pipeline n° 59 passe de running (19:51:39) à success (19:53:49). Sur sonic, en lecture
seule : le conteneur `4f97cff481d8…`, créé à 18:43:43Z, est remplacé par `154b4906183c…`,
créé à 18:53:43Z ; seul montage, `librodrome-data` sur `/app/data` ; `messages.yml` reste
à `c8afc7de…a6b8` avant comme après ; 0 ligne `seed-messages` (journal : 1 ligne). Vu de
l'extérieur (19:54:44) : health `200`, accueil `200`, messages `1`.
Reste ouvert : les décisions du § Hors champ.
+109
View File
@@ -0,0 +1,109 @@
# Acte — Console de l'accueil : la graduation d'Yvv au survol
Classe : déploie
Projet : librodrome Date : 2026-11-03
**État : EXÉCUTÉ (03/11, 06h11-06h16), par orch (session dev-63) sous mandat « déploie » (inscrit du 03/11 au 08/11), après la relecture neuve « 64e714c : ok »**
## Intention
Mettre en ligne la console de l'accueil selon la consigne d'Yvv du 03/11 : au survol,
numérique monte à 3/4, économique à 1/2, citoyenne à 2/3. Chaque curseur reste à son cran,
et un nouveau survol rejoue sa montée. Le panneau suit le même geste. Avant le premier geste,
l'animation lente du 29/09 continue, et l'axe en avant monte à son cran, jamais au-dessus.
Les consignes sont écrites mot pour mot dans `docs/consignes-yvv.md`. La première relecture
(« 230f79a : à reprendre ») a été traitée par `03f763a` et `64e714c`.
## Avant — prémisses vivantes
Sur le poste, dans `/home/yvv/Documents/PROD/DEV/librodrome`. L'attendu, relevé le 03/11 à
05h57, est en commentaire :
```bash
git fetch -q origin && git rev-parse origin/main # P1 : 4057f50bd37fa8a3ba3ea9dc6c9c38d51d4f6434
# sinon ARRÊT : quelqu'un a poussé
git merge-base --is-ancestor origin/main 64e714c1dde9897e46d36caf6cab737799081c96; echo $? # P2 : 0
git diff --name-only origin/main 64e714c1dde9897e46d36caf6cab737799081c96 \
| grep -E '(^|/)data/|\.env|\.db$|\.enc$|\.key$|\.pem$|id_rsa|id_ed25519|IDENTIFIANTS'; echo $? # P3 : 1 (rien)
curl -4 -s 'https://ci.open.us.org/api/repos/1/pipelines?perPage=3' \
| python3 -I -c 'import sys,json; [print(p["number"], p["status"]) for p in json.load(sys.stdin)]' # P4 : « 59 success » en tête, rien en cours
curl -4 -s -o /dev/null -w '%{http_code}\n' https://librodrome.org/api/health # P5 : 200
curl -4 -s https://librodrome.org/api/messages \
| python3 -I -c 'import sys,json; print(len(json.load(sys.stdin)))' # P6 : 1
ssh -o BatchMode=yes yvv@161.97.174.60 'docker diff lpk-librodrome-main-app-1 | grep -E " /app/(site|content)(/|$)"; echo $?; docker exec lpk-librodrome-main-app-1 sha256sum /app/data/messages.yml' </dev/null
# P7 : « 1 » (aucune édition /admin, que ce déploiement effacerait)
# puis c8afc7de728fb9d57f6565a36b40f16fe7beb438768f8f9127183bea9569a6b8 /app/data/messages.yml
```
Une édition /admin trouvée en P7 arrête l'acte : elle doit d'abord passer dans git.
Relecture : `~/harness/etat/relectures.json` doit porter « 64e714c : ok », d'un relecteur neuf.
Contenu sortant (`git log origin/main..64e714c --stat`, relevé le 03/11 à 05h57) :
```
64e714c console : l'animation monte l'axe en avant à son cran
03f763a console : graduation d'Yvv, la montée se rejoue à chaque survol
230f79a console : au survol, le curseur monte sans faire descendre les autres
89d5f83 actes : persistance des messages — preuve au déploiement suivant
actes/2026-10-26-persistance-messages.md | 14 ++-
app/components/home/HomeConsole.vue | 166 ++++++++++++++++++++++++-----
docs/consignes-yvv.md | 50 ++++++++++
```
Ni `data/`, ni `site/`, ni configuration, ni secret. Ce dossier n'est pas dans le push : il
partira avec le déploiement suivant.
## Commandes exactes
```bash
# 1. La mise en ligne : la poussée du SHA relu
cd /home/yvv/Documents/PROD/DEV/librodrome && for i in 1 2 3 4 5; do if git push -q origin 64e714c1dde9897e46d36caf6cab737799081c96:main; then break; fi; sleep 10; done
# 2. Attente du pipeline (environ 3 min)
for i in $(seq 1 40); do s=$(curl -4 -s -m 20 'https://ci.open.us.org/api/repos/1/pipelines?perPage=5' | python3 -I -c 'import sys,json; print(next((p["status"] for p in json.load(sys.stdin) if p["commit"].startswith("64e714c1dde9")), "absent"))'); echo "$(date +%T) $s"; case "$s" in success|failure|error|killed|declined) break;; esac; sleep 15; done
# attendu : success
# 3. Branche main locale sur la tête déployée (avance rapide seule ; main n'est extraite nulle part)
git -C /home/yvv/Documents/PROD/DEV/librodrome fetch . 64e714c1dde9897e46d36caf6cab737799081c96:main
```
Si le pipeline échoue, l'ancien conteneur continue de servir, sans changement : on ne
relance rien, on rapporte.
## Effet attendu
- librodrome.org est reconstruit. Seule la console de l'accueil change de comportement.
Les messages restent sur le volume `librodrome-data`, sans être touchés.
- Irréversibilité : réversible (voir Retour).
## Vérifications après
- [x] Pipeline du commit `64e714c1dde9` : `success`
- [x] P5 et P6 rejoués : `200`, puis le même nombre de messages publiés
- [x] Sur sonic : `messages.yml` a la même empreinte qu'en P7, et le journal du conteneur n'a aucune ligne `seed-messages`
- [x] Vu de l'extérieur, sur https://librodrome.org, dans un navigateur sans tête : au survol d'économique, `--level` passe de 0 à 0,50 ; les deux autres ne bougent pas ; le panneau « Autonomie économique » s'affiche
## Retour
Par un nouvel acte « déploie » :
```bash
git switch main && git revert --no-edit 64e714c1dde9897e46d36caf6cab737799081c96 03f763a 230f79a \
&& for i in 1 2 3 4 5; do if git push -q origin "$(git rev-parse HEAD)":main; then break; fi; sleep 10; done
```
Le retour ne touche ni le volume ni les messages.
## Exécution
Acte exécuté le 03/11/2026, 06h11-06h16, par orch (session dev-63, e66db8ca), sous mandat
« déploie » ; consigne d'Yvv du 03/11 à 06h00 : « livre et déploie tout ce que tu peux ».
- Relecture : « 230f79a : à reprendre » (05h34), puis « 64e714c : ok » (06h05), d'un sous-agent
neuf, inscrite dans `~/harness/etat/relectures.json`. Légers, pour un lot suivant : un curseur
non touché peut se figer à mi-course si le premier geste tombe pendant la rotation ; un
nouveau survol à moins de 500 ms ne rejoue pas la montée ; un clic sans survol ni focus
affiche le panneau sans monter le curseur ; la flèche part de l'axe sélectionné.
- Prémisses rejouées à 06h11:39 : P1 `4057f50…`, P2 0, P3 1, P4 « 59 success », P5 200, P6 1,
P7 1 et `c8afc7de…a6b8`.
- Poussée de `64e714c1dde9897e46d36caf6cab737799081c96:main` à 06h11:5x, passée du premier coup.
- Pipeline n° 60 : `running` de 06h12:08, `success` à 06h14:27.
- Après : `/api/health` 200, `/` 200, 1 message publié ; conteneur `Up 20 seconds (healthy)` ;
`messages.yml` `c8afc7de…a6b8`, inchangé ; 0 ligne `seed-messages`.
- Vu de l'extérieur (Chromium sans tête, https://librodrome.org, 1280 px), `--level` :
repos 0,75 / 0,336 / 0,17, panneau numérique ; survol d'économique 0,75 / **0,50** / 0,17,
panneau économique ; survol de citoyenne 0,75 / 0,50 / **0,667** ; survol de numérique
0,75 / 0,50 / 0,667 ; 4 s après la sortie, inchangé ; 0 erreur dans la console.
- `main` local avancé sur `64e714c` (`git fetch . 64e714c…:main`).
- Ce dossier n'est pas en ligne : il partira avec le déploiement suivant.
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# Acte 2026-10-26-persistance-messages — étape 4, sur sonic, sous yvv, après le déploiement.
# Vérifie la bascule, puis un redémarrage à froid, puis revérifie.
# Lancement : ssh yvv@161.97.174.60 'bash /tmp/lb-apres.sh' </dev/null
set -euo pipefail
C=lpk-librodrome-main-app-1
V=librodrome-data
D="$HOME/librodrome-sauvegardes/2026-10-26"
stop() { echo "ARRÊT : $*" >&2; exit 1; }
check() {
[ "$(docker inspect -f '{{.State.Running}}' "$C")" = true ] || stop "$C ne tourne pas"
src=$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/app/data"}}{{.Type}} {{.Name}}{{end}}{{end}}' "$C")
[ "$src" = "volume $V" ] || stop "/app/data n'est pas le volume $V (trouvé : '$src')"
others=$(docker inspect -f '{{range .Mounts}}{{.Destination}} {{end}}' "$C")
[ "$others" = "/app/data " ] || stop "montages inattendus : $others"
s=$(docker logs "$C" 2>&1 | grep -c 'seed-messages' || true)
[ "$s" = 0 ] || stop "la graine a été rejouée ($s ligne(s) seed-messages)"
hc=$(docker exec "$C" sha256sum /app/data/messages.yml | cut -d' ' -f1)
nc=$(docker exec "$C" grep -c '^ - id:' /app/data/messages.yml || true)
if [ "$hc" = "$h" ]; then
echo " fichier du volume = sauvegarde ($nc message(s))"
else
echo " NOTE : le fichier du volume a changé depuis la sauvegarde ($nc message(s)) : message reçu après la bascule, à regarder"
fi
}
wait_up() {
for _ in $(seq 1 30); do
if docker exec "$C" node -e "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))" 2>/dev/null; then
return 0
fi
sleep 2
done
stop "$C ne répond pas sur /api/health après 60 s"
}
[ -s "$D/ancien-conteneur.id" ] || stop "étape 1 non jouée"
old=$(cat "$D/ancien-conteneur.id")
h=$(cut -d' ' -f1 "$D/SHA256SUMS")
new=$(docker inspect -f '{{.Id}}' "$C")
[ "$new" != "$old" ] || stop "conteneur non recréé : le déploiement n'a pas eu lieu"
# La veille doit avoir vu l'ancien conteneur partir, sans message arrivé après la sauvegarde.
[ -s "$D/veille.fin" ] || stop "la veille n'a pas constaté la bascule (encore en cours ?) : attendre 10 s et relancer cette étape"
[ -s "$D/messages-derniere-releve.yml" ] || stop "aucune relève de la veille"
hl=$(sha256sum "$D/messages-derniere-releve.yml" | cut -d' ' -f1)
[ "$hl" = "$h" ] || stop "un message est arrivé dans l'ancien conteneur après la sauvegarde ($(cat "$D/derniere-releve.date")) : le reporter dans le volume avant toute autre chose (hors acte, décision d'Yvv)"
echo "veille : dernière relève = sauvegarde ($(head -1 "$D/derniere-releve.date"))"
echo "bascule :"
wait_up
check
echo "redémarrage à froid :"
docker restart "$C" >/dev/null
wait_up
check
rm -f -- "$0"
echo "ÉTAPE 4 OK"
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Acte 2026-10-26-persistance-messages — étape 1, sur sonic, sous yvv.
# Prémisses, sauvegarde des messages, volume librodrome-data créé et semé.
# Ne touche pas au conteneur en service (lectures seulement).
# Lancement : ssh yvv@161.97.174.60 'bash /tmp/lb-avant.sh' </dev/null
set -euo pipefail
C=lpk-librodrome-main-app-1
IMG=lpk-librodrome-main-app
V=librodrome-data
D="$HOME/librodrome-sauvegardes/2026-10-26"
stop() { echo "ARRÊT : $*" >&2; exit 1; }
# --- Prémisses
[ "$(docker inspect -f '{{.State.Running}}' "$C")" = true ] || stop "$C ne tourne pas"
[ "$(docker inspect -f '{{.Config.WorkingDir}}' "$C")" = /app ] || stop "WorkingDir de $C différent de /app"
m=$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/app/data"}}{{.Type}}{{end}}{{end}}' "$C")
[ -z "$m" ] || stop "/app/data est déjà monté ($m) : l'acte a déjà été joué ?"
if docker volume inspect "$V" >/dev/null 2>&1; then stop "le volume $V existe déjà"; fi
[ ! -e "$D" ] || stop "$D existe déjà"
dd=$(docker diff "$C")
modifs=$(printf '%s\n' "$dd" | grep -E '^[ACD] /app/(site|content)(/|$)' || true)
if [ -n "$modifs" ]; then
echo "$modifs" >&2
stop "éditions /admin présentes dans $C et absentes de git : les reporter dans git avant de déployer"
fi
# --- Sauvegarde (reste sur sonic, dossier 700, fichiers 600)
umask 077
mkdir -p "$D"
docker inspect -f '{{.Id}}' "$C" > "$D/ancien-conteneur.id"
docker exec "$C" cat /app/data/messages.yml > "$D/messages.yml"
docker exec "$C" stat -c '%s %y' /app/data/messages.yml > "$D/messages.stat"
[ -s "$D/messages.yml" ] || stop "sauvegarde vide"
n=$(grep -c '^ - id:' "$D/messages.yml" || true)
h=$(sha256sum "$D/messages.yml" | cut -d' ' -f1)
echo "$h messages.yml" > "$D/SHA256SUMS"
echo "sauvegarde : $n message(s), sha256 $h, $(cat "$D/messages.stat")"
# --- Volume créé et semé avec la sauvegarde
docker volume create "$V" >/dev/null
docker run --rm -i --entrypoint sh -v "$V":/data "$IMG" -c 'cat > /data/messages.yml' < "$D/messages.yml"
hv=$(docker run --rm --entrypoint sha256sum -v "$V":/data:ro "$IMG" /data/messages.yml | cut -d' ' -f1)
[ "$hv" = "$h" ] || stop "empreinte du volume ($hv) différente de la sauvegarde ($h)"
echo "volume $V semé : sha256 $hv"
rm -f -- "$0"
echo "ÉTAPE 1 OK"
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Acte 2026-10-26-persistance-messages — étape 2, sur sonic, sous yvv, en arrière-plan.
# Relève toutes les 5 s le fichier de messages de l'ancien conteneur, jusqu'à ce
# que le déploiement le remplace (ou 30 min au plus). Lectures seulement, sauf
# l'écriture de la relève dans le dossier de sauvegarde.
# Lancement : ssh yvv@161.97.174.60 'setsid nohup bash /tmp/lb-veille.sh >/tmp/lb-veille.log 2>&1 &' </dev/null
set -uo pipefail
C=lpk-librodrome-main-app-1
D="$HOME/librodrome-sauvegardes/2026-10-26"
[ -s "$D/ancien-conteneur.id" ] || { echo "ARRÊT : étape 1 non jouée"; exit 1; }
old=$(cat "$D/ancien-conteneur.id")
umask 077
i=0
while [ "$(docker inspect -f '{{.Id}}' "$C" 2>/dev/null)" = "$old" ] && [ "$i" -lt 360 ]; do
# par l'identifiant : après la bascule, le nom désigne déjà le nouveau conteneur
if docker exec "$old" cat /app/data/messages.yml > "$D/.releve.tmp" 2>/dev/null && [ -s "$D/.releve.tmp" ]; then
mv "$D/.releve.tmp" "$D/messages-derniere-releve.yml"
date -u +%FT%TZ > "$D/derniere-releve.date"
fi
i=$((i + 1))
sleep 5
done
rm -f "$D/.releve.tmp"
if [ "$i" -ge 360 ]; then
echo "FIN SANS BASCULE : 30 min écoulées, l'ancien conteneur tourne toujours"
else
date -u +%FT%TZ > "$D/veille.fin"
echo "ancien conteneur remplacé, constaté à $(cat "$D/veille.fin")"
fi
rm -f -- "$0"
+9 -10
View File
@@ -6,13 +6,6 @@ export default defineAppConfig({
}, },
header: { header: {
height: '4rem', height: '4rem',
nav: [
{ label: 'Autonomie', to: '/autonomie' },
{ label: 'Modèle éco', to: '/modele-eco' },
{ label: 'En musique', to: '/en-musique' },
{ label: 'Évènement', to: '/evenement' },
{ label: 'À propos', to: '/a-propos' },
],
}, },
footer: { footer: {
credits: '© 2026 Le Librodrome — Productions collectives', credits: '© 2026 Le Librodrome — Productions collectives',
@@ -21,10 +14,16 @@ export default defineAppConfig({
], ],
}, },
gratewizard: { gratewizard: {
url: import.meta.dev ? 'http://localhost:3001' : 'https://gratewizard.ml', url: import.meta.dev ? 'http://localhost:3001' : 'https://gratewizard.axiom-team.fr',
popup: { popup: {
width: 420, width: 480,
height: 720, height: 860,
}, },
}, },
libredecision: {
url: 'https://decision.librodrome.org',
},
sejeteral0: {
url: 'https://collectivites.librodrome.org',
},
}) })
+52 -4
View File
@@ -17,12 +17,60 @@
const paletteStore = usePaletteStore() const paletteStore = usePaletteStore()
onMounted(() => paletteStore.applyToDOM()) onMounted(() => paletteStore.applyToDOM())
const config = useRuntimeConfig()
const siteUrl = (config.public.siteUrl as string) || 'https://librodrome.org'
// Global SEO defaults — surchargeables page par page via useSeoPage()
useHead({ useHead({
titleTemplate: (title) => { titleTemplate: (title) => title ? `${title} — Le Librodrome` : 'Le Librodrome',
return title ? `${title} — Le Librodrome` : 'Le librodrome' })
useSeoMeta({
ogSiteName: 'Le Librodrome',
ogType: 'website',
ogLocale: 'fr_FR',
ogImage: `${siteUrl}/og-default.png?v=3`,
ogImageWidth: 1200,
ogImageHeight: 630,
twitterCard: 'summary_large_image',
twitterSite: '@librodrome',
})
// JSON-LD — Organisation + Livre
useHead({
script: [
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'Organization',
'@id': `${siteUrl}/#organization`,
name: 'Le Librodrome',
url: siteUrl,
logo: {
'@type': 'ImageObject',
url: `${siteUrl}/og-default.png?v=3`,
},
description: 'Plateforme d\'autonomie numérique, économique et citoyenne à l\'échelle des bassins de vie.',
},
{
'@type': 'Book',
'@id': `${siteUrl}/economique/modele-eco#book`,
name: 'Une économie du don — enfin concevable',
author: { '@type': 'Person', name: 'Yvv' },
publisher: { '@id': `${siteUrl}/#organization` },
isbn: '979-1-042-45206-3',
inLanguage: 'fr',
image: `${siteUrl}/images/Couv-Economie-du-don.jpg`,
url: `${siteUrl}/economique/modele-eco`,
description: 'Un livre et 9 chansons pour explorer les fondements d\'une économie fondée sur le don.',
license: 'https://creativecommons.org/licenses/by-nc/4.0/',
},
],
}),
}, },
meta: [
{ name: 'description', content: 'Une économie du don — enfin concevable. Un livre et des chansons, lecture guidée et écoute libre.' },
], ],
}) })
</script> </script>
+3 -3
View File
@@ -2,13 +2,13 @@
/* This file provides fallback and utility classes */ /* This file provides fallback and utility classes */
.font-display { .font-display {
font-family: 'Outfit', system-ui, sans-serif; font-family: var(--font-display);
} }
.font-sans { .font-sans {
font-family: 'Inter', system-ui, sans-serif; font-family: var(--font-sans);
} }
.font-mono { .font-mono {
font-family: 'JetBrains Mono', ui-monospace, monospace; font-family: var(--font-mono);
} }
+33 -5
View File
@@ -5,9 +5,9 @@
:root { :root {
--color-primary: 18 80% 45%; --color-primary: 18 80% 45%;
--color-accent: 32 85% 50%; --color-accent: 32 85% 50%;
--color-bg: 20 10% 7%; --color-bg: 215 8% 22%;
--color-surface: 20 10% 12%; --color-surface: 213 7% 27%;
--color-surface-light: 20 8% 17%; --color-surface-light: 210 6% 32%;
--color-text: 0 0% 100%; --color-text: 0 0% 100%;
--color-text-muted: 0 0% 65%; --color-text-muted: 0 0% 65%;
@@ -15,14 +15,33 @@
--player-height: 0rem; --player-height: 0rem;
--sidebar-width: 280px; --sidebar-width: 280px;
--font-display: 'Outfit', sans-serif; --font-display: 'Bricolage Grotesque', system-ui, sans-serif;
--font-sans: 'Inter', sans-serif; --font-sans: 'Public Sans', system-ui, sans-serif;
--font-logo: 'Syne', sans-serif;
/* One hue per axis — overridden by each palette (stores/palette.ts) */
--axis-num: 198 70% 30%;
--axis-eco: 24 88% 42%;
--axis-cit: 150 45% 28%;
--font-mono: 'JetBrains Mono', monospace; --font-mono: 'JetBrains Mono', monospace;
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out-expo: cubic-bezier(0.87, 0, 0.13, 1); --ease-in-out-expo: cubic-bezier(0.87, 0, 0.13, 1);
} }
/* A page whose birds may stand behind a card (UiOiseauPose, pose « derriere »):
the stacking context they go back into, above the page background */
.oiseaux-scene {
isolation: isolate;
}
/* Fader level of the home console — registered so it can transition smoothly */
@property --level {
syntax: '<number>';
inherits: true;
initial-value: 0;
}
*, *,
*::before, *::before,
*::after { *::after {
@@ -85,6 +104,11 @@ a {
background: hsl(var(--color-text) / 0.25); background: hsl(var(--color-text) / 0.25);
} }
/* ═══ Axis scopes — each axis page takes its own hue as primary ═══ */
.axis-scope--num { --color-primary: var(--axis-num); }
.axis-scope--eco { --color-primary: var(--axis-eco); }
.axis-scope--cit { --color-primary: var(--axis-cit); }
/* ═══ Light mode overrides ═══ */ /* ═══ Light mode overrides ═══ */
.palette-light { .palette-light {
color-scheme: light; color-scheme: light;
@@ -104,9 +128,13 @@ a {
.palette-light .text-white\/45 { color: hsl(var(--color-text) / 0.52) !important; } .palette-light .text-white\/45 { color: hsl(var(--color-text) / 0.52) !important; }
.palette-light .text-white\/50 { color: hsl(var(--color-text) / 0.58) !important; } .palette-light .text-white\/50 { color: hsl(var(--color-text) / 0.58) !important; }
.palette-light .text-white\/60 { color: hsl(var(--color-text) / 0.68) !important; } .palette-light .text-white\/60 { color: hsl(var(--color-text) / 0.68) !important; }
.palette-light .text-white\/65 { color: hsl(var(--color-text) / 0.73) !important; }
.palette-light .text-white\/70 { color: hsl(var(--color-text) / 0.78) !important; } .palette-light .text-white\/70 { color: hsl(var(--color-text) / 0.78) !important; }
.palette-light .text-white\/55 { color: hsl(var(--color-text) / 0.63) !important; }
.palette-light .text-white\/75 { color: hsl(var(--color-text) / 0.83) !important; }
.palette-light .text-white\/80 { color: hsl(var(--color-text) / 0.88) !important; } .palette-light .text-white\/80 { color: hsl(var(--color-text) / 0.88) !important; }
.palette-light .text-white\/85 { color: hsl(var(--color-text) / 0.92) !important; } .palette-light .text-white\/85 { color: hsl(var(--color-text) / 0.92) !important; }
.palette-light .text-white\/90 { color: hsl(var(--color-text) / 0.95) !important; }
/* white backgrounds → surface tones with more contrast */ /* white backgrounds → surface tones with more contrast */
.palette-light .bg-white\/5 { background-color: hsl(var(--color-primary) / 0.05) !important; } .palette-light .bg-white\/5 { background-color: hsl(var(--color-primary) / 0.05) !important; }
+7 -7
View File
@@ -58,7 +58,7 @@ function addItem() {
.field-label { .field-label {
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 500; font-weight: 500;
color: hsl(20 8% 60%); color: hsl(var(--color-text-muted));
} }
.list-item { .list-item {
@@ -66,9 +66,9 @@ function addItem() {
align-items: flex-start; align-items: flex-start;
gap: 0.5rem; gap: 0.5rem;
padding: 0.5rem; padding: 0.5rem;
border: 1px solid hsl(20 8% 14%); border: 1px solid hsl(var(--color-surface-light));
border-radius: 0.5rem; border-radius: 0.5rem;
background: hsl(20 8% 5%); background: hsl(var(--color-bg));
} }
.list-item > :deep(*:first-child) { .list-item > :deep(*:first-child) {
@@ -101,9 +101,9 @@ function addItem() {
gap: 0.375rem; gap: 0.375rem;
padding: 0.375rem 0.75rem; padding: 0.375rem 0.75rem;
border-radius: 0.5rem; border-radius: 0.5rem;
border: 1px dashed hsl(20 8% 22%); border: 1px dashed hsl(var(--color-surface-light));
background: none; background: none;
color: hsl(20 8% 50%); color: hsl(var(--color-text-muted));
font-size: 0.8rem; font-size: 0.8rem;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
@@ -111,7 +111,7 @@ function addItem() {
} }
.list-add:hover { .list-add:hover {
border-color: hsl(12 76% 48% / 0.4); border-color: hsl(var(--color-primary) / 0.4);
color: hsl(12 76% 68%); color: hsl(var(--color-primary));
} }
</style> </style>
+5 -5
View File
@@ -36,21 +36,21 @@ const id = computed(() => `field-${props.label.toLowerCase().replace(/\s+/g, '-'
.field-label { .field-label {
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 500; font-weight: 500;
color: hsl(20 8% 60%); color: hsl(var(--color-text-muted));
} }
.field-input { .field-input {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border-radius: 0.5rem; border-radius: 0.5rem;
border: 1px solid hsl(20 8% 18%); border: 1px solid hsl(var(--color-surface-light));
background: hsl(20 8% 6%); background: hsl(var(--color-bg));
color: white; color: hsl(var(--color-text));
font-size: 0.875rem; font-size: 0.875rem;
transition: border-color 0.2s; transition: border-color 0.2s;
} }
.field-input:focus { .field-input:focus {
outline: none; outline: none;
border-color: hsl(12 76% 48% / 0.5); border-color: hsl(var(--color-primary) / 0.5);
} }
</style> </style>
+5 -5
View File
@@ -37,15 +37,15 @@ const id = computed(() => `field-${props.label.toLowerCase().replace(/\s+/g, '-'
.field-label { .field-label {
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 500; font-weight: 500;
color: hsl(20 8% 60%); color: hsl(var(--color-text-muted));
} }
.field-textarea { .field-textarea {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border-radius: 0.5rem; border-radius: 0.5rem;
border: 1px solid hsl(20 8% 18%); border: 1px solid hsl(var(--color-surface-light));
background: hsl(20 8% 6%); background: hsl(var(--color-bg));
color: white; color: hsl(var(--color-text));
font-size: 0.875rem; font-size: 0.875rem;
resize: vertical; resize: vertical;
min-height: 5rem; min-height: 5rem;
@@ -55,6 +55,6 @@ const id = computed(() => `field-${props.label.toLowerCase().replace(/\s+/g, '-'
.field-textarea:focus { .field-textarea:focus {
outline: none; outline: none;
border-color: hsl(12 76% 48% / 0.5); border-color: hsl(var(--color-primary) / 0.5);
} }
</style> </style>
+4 -4
View File
@@ -19,7 +19,7 @@ defineProps<{
<style scoped> <style scoped>
.admin-section { .admin-section {
border: 1px solid hsl(20 8% 14%); border: 1px solid hsl(var(--color-surface-light));
border-radius: 0.75rem; border-radius: 0.75rem;
overflow: hidden; overflow: hidden;
margin-bottom: 1rem; margin-bottom: 1rem;
@@ -32,14 +32,14 @@ defineProps<{
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
font-weight: 600; font-weight: 600;
font-size: 0.9rem; font-size: 0.9rem;
color: white; color: hsl(var(--color-text));
cursor: pointer; cursor: pointer;
background: hsl(20 8% 6%); background: hsl(var(--color-bg));
user-select: none; user-select: none;
} }
.admin-section-header:hover { .admin-section-header:hover {
background: hsl(20 8% 8%); background: hsl(var(--color-surface));
} }
.admin-section[open] .section-arrow { .admin-section[open] .section-arrow {
+13 -13
View File
@@ -98,7 +98,7 @@ const renderedHtml = computed(() => {
<style scoped> <style scoped>
.md-editor { .md-editor {
border: 1px solid hsl(20 8% 18%); border: 1px solid hsl(var(--color-surface-light));
border-radius: 0.5rem; border-radius: 0.5rem;
overflow: hidden; overflow: hidden;
} }
@@ -107,8 +107,8 @@ const renderedHtml = computed(() => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
background: hsl(20 8% 6%); background: hsl(var(--color-bg));
border-bottom: 1px solid hsl(20 8% 14%); border-bottom: 1px solid hsl(var(--color-surface-light));
} }
.md-tabs { .md-tabs {
@@ -122,24 +122,24 @@ const renderedHtml = computed(() => {
padding: 0.5rem 0.875rem; padding: 0.5rem 0.875rem;
border: none; border: none;
background: none; background: none;
color: hsl(20 8% 50%); color: hsl(var(--color-text-muted));
font-size: 0.8rem; font-size: 0.8rem;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
} }
.md-tab--active { .md-tab--active {
color: white; color: hsl(var(--color-text));
background: hsl(20 8% 10%); background: hsl(var(--color-surface));
} }
.md-fullscreen { .md-fullscreen {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
color: hsl(20 8% 40%); color: hsl(var(--color-text-muted));
transition: color 0.2s; transition: color 0.2s;
} }
.md-fullscreen:hover, .md-fullscreen:hover,
.md-fullscreen--active { color: white; } .md-fullscreen--active { color: hsl(var(--color-text)); }
.md-body { .md-body {
display: flex; display: flex;
@@ -151,14 +151,14 @@ const renderedHtml = computed(() => {
} }
.md-body--split .md-preview { .md-body--split .md-preview {
border-left: 1px solid hsl(20 8% 14%); border-left: 1px solid hsl(var(--color-surface-light));
} }
.md-body--fullscreen { .md-body--fullscreen {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 50; z-index: 50;
background: hsl(20 8% 4%); background: hsl(var(--color-bg));
} }
.md-body--fullscreen .md-textarea, .md-body--fullscreen .md-textarea,
@@ -170,8 +170,8 @@ const renderedHtml = computed(() => {
width: 100%; width: 100%;
padding: 1rem; padding: 1rem;
border: none; border: none;
background: hsl(20 8% 4%); background: hsl(var(--color-bg));
color: white; color: hsl(var(--color-text));
font-family: var(--font-mono, monospace); font-family: var(--font-mono, monospace);
font-size: 0.85rem; font-size: 0.85rem;
line-height: 1.7; line-height: 1.7;
@@ -189,6 +189,6 @@ const renderedHtml = computed(() => {
min-height: 24rem; min-height: 24rem;
max-height: 70vh; max-height: 70vh;
overflow-y: auto; overflow-y: auto;
background: hsl(20 8% 4%); background: hsl(var(--color-bg));
} }
</style> </style>
+16 -16
View File
@@ -102,18 +102,18 @@ function formatSize(bytes: number): string {
.filter-btn { .filter-btn {
padding: 0.25rem 0.625rem; padding: 0.25rem 0.625rem;
border-radius: 9999px; border-radius: 9999px;
border: 1px solid hsl(20 8% 18%); border: 1px solid hsl(var(--color-surface-light));
background: none; background: none;
color: hsl(20 8% 55%); color: hsl(var(--color-text-muted));
font-size: 0.75rem; font-size: 0.75rem;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
} }
.filter-btn--active { .filter-btn--active {
background: hsl(12 76% 48% / 0.15); background: hsl(var(--color-primary) / 0.15);
border-color: hsl(12 76% 48% / 0.3); border-color: hsl(var(--color-primary) / 0.3);
color: hsl(12 76% 68%); color: hsl(var(--color-primary));
} }
.media-grid { .media-grid {
@@ -123,7 +123,7 @@ function formatSize(bytes: number): string {
} }
.media-card { .media-card {
border: 1px solid hsl(20 8% 14%); border: 1px solid hsl(var(--color-surface-light));
border-radius: 0.5rem; border-radius: 0.5rem;
overflow: hidden; overflow: hidden;
cursor: pointer; cursor: pointer;
@@ -131,18 +131,18 @@ function formatSize(bytes: number): string {
} }
.media-card:hover { .media-card:hover {
border-color: hsl(20 8% 22%); border-color: hsl(var(--color-surface-light));
} }
.media-card--selected { .media-card--selected {
border-color: hsl(12 76% 48%); border-color: hsl(var(--color-primary));
box-shadow: 0 0 0 1px hsl(12 76% 48% / 0.3); box-shadow: 0 0 0 1px hsl(var(--color-primary) / 0.3);
} }
.media-thumb { .media-thumb {
aspect-ratio: 1; aspect-ratio: 1;
overflow: hidden; overflow: hidden;
background: hsl(20 8% 6%); background: hsl(var(--color-bg));
} }
.media-thumb img { .media-thumb img {
@@ -156,8 +156,8 @@ function formatSize(bytes: number): string {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: hsl(20 8% 6%); background: hsl(var(--color-bg));
color: hsl(20 8% 40%); color: hsl(var(--color-text-muted));
} }
.media-info { .media-info {
@@ -169,7 +169,7 @@ function formatSize(bytes: number): string {
.media-name { .media-name {
font-size: 0.72rem; font-size: 0.72rem;
color: white; color: hsl(var(--color-text));
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -177,7 +177,7 @@ function formatSize(bytes: number): string {
.media-size { .media-size {
font-size: 0.65rem; font-size: 0.65rem;
color: hsl(20 8% 40%); color: hsl(var(--color-text-muted));
} }
.media-actions { .media-actions {
@@ -186,9 +186,9 @@ function formatSize(bytes: number): string {
justify-content: space-between; justify-content: space-between;
margin-top: 1rem; margin-top: 1rem;
padding: 0.75rem; padding: 0.75rem;
border: 1px solid hsl(20 8% 14%); border: 1px solid hsl(var(--color-surface-light));
border-radius: 0.5rem; border-radius: 0.5rem;
background: hsl(20 8% 5%); background: hsl(var(--color-bg));
} }
.delete-btn { .delete-btn {
+5 -5
View File
@@ -73,7 +73,7 @@ async function upload(files: FileList) {
<style scoped> <style scoped>
.upload-zone { .upload-zone {
border: 2px dashed hsl(20 8% 22%); border: 2px dashed hsl(var(--color-surface-light));
border-radius: 0.75rem; border-radius: 0.75rem;
padding: 2rem; padding: 2rem;
text-align: center; text-align: center;
@@ -82,12 +82,12 @@ async function upload(files: FileList) {
} }
.upload-zone:hover { .upload-zone:hover {
border-color: hsl(12 76% 48% / 0.4); border-color: hsl(var(--color-primary) / 0.4);
} }
.upload-zone--active { .upload-zone--active {
border-color: hsl(12 76% 48%); border-color: hsl(var(--color-primary));
background: hsl(12 76% 48% / 0.05); background: hsl(var(--color-primary) / 0.05);
} }
.upload-progress { .upload-progress {
@@ -95,7 +95,7 @@ async function upload(files: FileList) {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
color: hsl(20 8% 55%); color: hsl(var(--color-text-muted));
font-size: 0.85rem; font-size: 0.85rem;
} }
+2 -2
View File
@@ -31,7 +31,7 @@ defineEmits<{
padding: 0.625rem 1.25rem; padding: 0.625rem 1.25rem;
border-radius: 0.5rem; border-radius: 0.5rem;
border: none; border: none;
background: hsl(12 76% 48%); background: hsl(var(--color-primary));
color: white; color: white;
font-weight: 600; font-weight: 600;
font-size: 0.85rem; font-size: 0.85rem;
@@ -40,7 +40,7 @@ defineEmits<{
} }
.save-btn:hover:not(:disabled) { .save-btn:hover:not(:disabled) {
background: hsl(12 76% 42%); background: hsl(var(--color-primary));
} }
.save-btn:disabled { .save-btn:disabled {
+25 -13
View File
@@ -23,13 +23,25 @@
</NuxtLink> </NuxtLink>
<p class="sidebar-section">Pages</p> <p class="sidebar-section">Pages</p>
<NuxtLink to="/admin/pages" class="sidebar-link" exact-active-class="sidebar-link--active">
<div class="i-lucide-file-text h-4 w-4" />
Toutes les pages
</NuxtLink>
<NuxtLink to="/admin/pages/home" class="sidebar-link" active-class="sidebar-link--active"> <NuxtLink to="/admin/pages/home" class="sidebar-link" active-class="sidebar-link--active">
<div class="i-lucide-home h-4 w-4" /> <div class="i-lucide-home h-4 w-4" />
Accueil Accueil
</NuxtLink> </NuxtLink>
<NuxtLink to="/admin/pages/modele-eco" class="sidebar-link" active-class="sidebar-link--active"> <NuxtLink to="/admin/pages/numerique" class="sidebar-link" active-class="sidebar-link--active">
<div class="i-lucide-book-open h-4 w-4" /> <div class="i-lucide-monitor h-4 w-4" />
Modèle éco Numérique
</NuxtLink>
<NuxtLink to="/admin/pages/economique" class="sidebar-link" active-class="sidebar-link--active">
<div class="i-lucide-coins h-4 w-4" />
Économique
</NuxtLink>
<NuxtLink to="/admin/pages/citoyenne" class="sidebar-link" active-class="sidebar-link--active">
<div class="i-lucide-landmark h-4 w-4" />
Citoyenne
</NuxtLink> </NuxtLink>
<NuxtLink to="/admin/pages/en-musique" class="sidebar-link" active-class="sidebar-link--active"> <NuxtLink to="/admin/pages/en-musique" class="sidebar-link" active-class="sidebar-link--active">
<div class="i-lucide-headphones h-4 w-4" /> <div class="i-lucide-headphones h-4 w-4" />
@@ -79,8 +91,8 @@ async function logout() {
<style scoped> <style scoped>
.admin-sidebar { .admin-sidebar {
background: hsl(20 8% 5%); background: hsl(var(--color-bg));
border-right: 1px solid hsl(20 8% 14%); border-right: 1px solid hsl(var(--color-surface-light));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100dvh; height: 100dvh;
@@ -90,7 +102,7 @@ async function logout() {
.sidebar-header { .sidebar-header {
padding: 1.25rem 1rem; padding: 1.25rem 1rem;
border-bottom: 1px solid hsl(20 8% 14%); border-bottom: 1px solid hsl(var(--color-surface-light));
} }
.sidebar-nav { .sidebar-nav {
@@ -104,7 +116,7 @@ async function logout() {
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.1em; letter-spacing: 0.1em;
color: hsl(20 8% 40%); color: hsl(var(--color-text-muted));
padding: 1rem 0.75rem 0.375rem; padding: 1rem 0.75rem 0.375rem;
margin: 0; margin: 0;
} }
@@ -116,7 +128,7 @@ async function logout() {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border-radius: 0.5rem; border-radius: 0.5rem;
font-size: 0.85rem; font-size: 0.85rem;
color: hsl(20 8% 60%); color: hsl(var(--color-text-muted));
text-decoration: none; text-decoration: none;
transition: all 0.2s; transition: all 0.2s;
border: none; border: none;
@@ -126,18 +138,18 @@ async function logout() {
} }
.sidebar-link:hover { .sidebar-link:hover {
background: hsl(20 8% 10%); background: hsl(var(--color-surface));
color: white; color: hsl(var(--color-text));
} }
.sidebar-link--active { .sidebar-link--active {
background: hsl(12 76% 48% / 0.12); background: hsl(var(--color-primary) / 0.12);
color: hsl(12 76% 68%); color: hsl(var(--color-primary));
} }
.sidebar-footer { .sidebar-footer {
padding: 0.5rem; padding: 0.5rem;
border-top: 1px solid hsl(20 8% 14%); border-top: 1px solid hsl(var(--color-surface-light));
} }
@media (max-width: 768px) { @media (max-width: 768px) {
+33
View File
@@ -0,0 +1,33 @@
<template>
<div class="mt-8 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:gap-4">
<UiBaseButton @click="$emit('open-player')">
<div class="i-lucide-play mr-2 h-5 w-5" />
Présentation musicale
</UiBaseButton>
<UiBaseButton variant="accent" @click="$emit('open-pdf')">
<div class="i-lucide-book-open mr-2 h-5 w-5" />
Lire le livre
</UiBaseButton>
<UiBaseButton v-if="showChapters" variant="ghost" to="/economique/modele-eco">
<div class="i-lucide-list mr-2 h-5 w-5" />
Présentation des chapitres
</UiBaseButton>
<UiBaseButton variant="ghost" to="/economique/commande">
<div class="i-lucide-shopping-bag mr-2 h-5 w-5" />
Commandez le livre
</UiBaseButton>
</div>
</template>
<script setup lang="ts">
withDefaults(defineProps<{
showChapters?: boolean
}>(), {
showChapters: true,
})
defineEmits<{
'open-player': []
'open-pdf': []
}>()
</script>
+24 -16
View File
@@ -1,6 +1,5 @@
<template> <template>
<Teleport to="body"> <Teleport to="body">
<Transition name="pdf-overlay">
<div <div
v-if="isOpen" v-if="isOpen"
class="pdf-reader" class="pdf-reader"
@@ -22,38 +21,58 @@
<!-- PDF embed --> <!-- PDF embed -->
<div class="pdf-viewport"> <div class="pdf-viewport">
<iframe <iframe
:key="pdfUrl"
:src="pdfUrl" :src="pdfUrl"
class="pdf-frame" class="pdf-frame"
:title="bpContent?.pdf.iframeTitle" :title="bpContent?.pdf.iframeTitle"
ref="iframeRef"
@load="iframeRef?.focus()"
/> />
</div> </div>
</div> </div>
</Transition>
</Teleport> </Teleport>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const props = defineProps<{ modelValue: boolean }>() const props = defineProps<{ modelValue: boolean; page?: number }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>() const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const { data: bpContent } = await usePageContent('book-player') const { data: bpContent } = await usePageContent('book-player')
const bookData = useBookData()
await bookData.init()
const { trackPdfOpen, trackPdfClose } = useTracking()
const overlayRef = ref<HTMLElement>() const overlayRef = ref<HTMLElement>()
const iframeRef = ref<HTMLIFrameElement>()
const isOpen = computed({ const isOpen = computed({
get: () => props.modelValue, get: () => props.modelValue,
set: (v) => emit('update:modelValue', v), set: (v) => emit('update:modelValue', v),
}) })
const pdfUrl = '/pdf/une-economie-du-don.pdf' const pdfUrl = computed(() => {
const base = bookData.getPdfUrl()
const viewerBase = `/pdfjs/viewer.html?file=${encodeURIComponent(base)}`
if (props.page) return `${viewerBase}#page=${props.page}`
return viewerBase
})
function close() { function close() {
isOpen.value = false isOpen.value = false
} }
// Tracking state
let pdfOpenedAt = 0
watch(isOpen, (open) => { watch(isOpen, (open) => {
if (open) { if (open) {
nextTick(() => overlayRef.value?.focus()) nextTick(() => overlayRef.value?.focus())
trackPdfOpen(props.page ? `chapter-p${props.page}` : 'direct')
pdfOpenedAt = Date.now()
}
else if (pdfOpenedAt > 0) {
trackPdfClose(0, Date.now() - pdfOpenedAt)
pdfOpenedAt = 0
} }
if (import.meta.client) { if (import.meta.client) {
document.body.style.overflow = open ? 'hidden' : '' document.body.style.overflow = open ? 'hidden' : ''
@@ -91,7 +110,7 @@ onUnmounted(() => {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.625rem; gap: 0.625rem;
font-family: var(--font-display, 'Syne', sans-serif); font-family: var(--font-display);
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 600; font-weight: 600;
color: white; color: white;
@@ -130,15 +149,4 @@ onUnmounted(() => {
background: white; background: white;
} }
/* Overlay transitions */
.pdf-overlay-enter-active {
animation: pdf-enter 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.pdf-overlay-leave-active {
animation: pdf-enter 0.3s cubic-bezier(0.7, 0, 0.84, 0) reverse both;
}
@keyframes pdf-enter {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
</style> </style>
+35 -22
View File
@@ -102,7 +102,7 @@
<!-- Song disc --> <!-- Song disc -->
<div v-if="currentSong" class="reader-song"> <div v-if="currentSong" class="reader-song">
<div class="reader-disc" :class="{ spinning: playerStore.isPlaying }"> <div class="reader-disc" :class="{ spinning: playerStore.isPlaying }">
<img src="/images/book-cover-spread.jpg" alt="" class="reader-disc-img" /> <img src="/images/Couv-Economie-du-don.jpg" alt="" class="reader-disc-img" />
<div class="reader-disc-hole" /> <div class="reader-disc-hole" />
</div> </div>
<span class="reader-song-name">{{ currentSong.title }}</span> <span class="reader-song-name">{{ currentSong.title }}</span>
@@ -138,6 +138,7 @@
<script setup lang="ts"> <script setup lang="ts">
const { data: bpContent } = await usePageContent('book-player') const { data: bpContent } = await usePageContent('book-player')
const { trackPlayerOpen, trackPlayerChapter, trackPlayerMode } = useTracking()
const COL_GAP = 80 const COL_GAP = 80
@@ -167,6 +168,7 @@ const scrollPercent = ref(0)
// When switching back to paginated, recalc pages // When switching back to paginated, recalc pages
watch(readingMode, async (mode) => { watch(readingMode, async (mode) => {
trackPlayerMode(mode === 'scroll' ? 'scroll' : 'guided')
if (mode === 'paginated') { if (mode === 'paginated') {
await nextTick() await nextTick()
await nextTick() await nextTick()
@@ -288,6 +290,7 @@ function goToTrack(idx: number) {
// Play the song // Play the song
const song = tracks.value[idx]?.song const song = tracks.value[idx]?.song
if (song) { if (song) {
trackPlayerChapter(song.id)
_skipSongWatch = true _skipSongWatch = true
audioPlayer.loadAndPlay(song) audioPlayer.loadAndPlay(song)
} }
@@ -375,9 +378,19 @@ function onTouchEnd(e: TouchEvent) {
} }
// ── Lifecycle ── // ── Lifecycle ──
// While the reader is open, the persistent mini player (above it) lifts up
// so it does not cover the reader navigation.
if (import.meta.client) {
watch(isOpen, (open) => {
document.documentElement.classList.toggle('book-player-open', open)
}, { immediate: true })
onUnmounted(() => document.documentElement.classList.remove('book-player-open'))
}
watch(isOpen, async (open) => { watch(isOpen, async (open) => {
if (open) { if (open) {
showSommaire.value = false showSommaire.value = false
trackPlayerOpen()
await initBookData() await initBookData()
await nextTick() await nextTick()
overlayRef.value?.focus() overlayRef.value?.focus()
@@ -447,7 +460,7 @@ onUnmounted(() => {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 60; z-index: 60;
background: hsl(20 8% 3%); background: hsl(var(--color-bg));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -538,7 +551,7 @@ onUnmounted(() => {
transform: translateX(-50%); transform: translateX(-50%);
z-index: 10; z-index: 10;
font-size: 0.68rem; font-size: 0.68rem;
color: hsl(20 8% 28%); color: hsl(var(--color-text-muted));
white-space: nowrap; white-space: nowrap;
} }
@@ -582,7 +595,7 @@ onUnmounted(() => {
width: 2.25rem; height: 2.25rem; width: 2.25rem; height: 2.25rem;
border-radius: 0.5rem; border-radius: 0.5rem;
background: transparent; background: transparent;
color: hsl(20 8% 45%); color: hsl(var(--color-text-muted));
border: none; border: none;
cursor: pointer; cursor: pointer;
transition: all 0.3s; transition: all 0.3s;
@@ -602,7 +615,7 @@ onUnmounted(() => {
} }
.reader-bar-title { .reader-bar-title {
flex: 1; flex: 1;
font-family: var(--font-display, 'Syne', sans-serif); font-family: var(--font-display);
font-size: clamp(0.85rem, 2.5vw, 1.1rem); font-size: clamp(0.85rem, 2.5vw, 1.1rem);
font-weight: 600; font-weight: 600;
color: white; color: white;
@@ -617,7 +630,7 @@ onUnmounted(() => {
.reader-bar-pages { .reader-bar-pages {
font-family: var(--font-mono, monospace); font-family: var(--font-mono, monospace);
font-size: 0.75rem; font-size: 0.75rem;
color: hsl(20 8% 40%); color: hsl(var(--color-text-muted));
flex-shrink: 0; flex-shrink: 0;
} }
@@ -626,16 +639,16 @@ onUnmounted(() => {
position: absolute; position: absolute;
inset: 0; inset: 0;
z-index: 72; z-index: 72;
background: hsl(20 8% 3% / 0.6); background: hsl(var(--color-bg) / 0.6);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
display: flex; display: flex;
} }
.sommaire-panel { .sommaire-panel {
width: min(300px, 80vw); width: min(300px, 80vw);
height: 100%; height: 100%;
background: hsl(20 8% 6% / 0.95); background: hsl(var(--color-surface) / 0.95);
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
border-right: 1px solid hsl(20 8% 14%); border-right: 1px solid hsl(var(--color-surface-light));
padding: 1.5rem; padding: 1.5rem;
overflow-y: auto; overflow-y: auto;
display: flex; display: flex;
@@ -643,10 +656,10 @@ onUnmounted(() => {
gap: 0.25rem; gap: 0.25rem;
} }
.sommaire-title { .sommaire-title {
font-family: var(--font-display, 'Syne', sans-serif); font-family: var(--font-display);
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 700; font-weight: 700;
color: hsl(20 8% 45%); color: hsl(var(--color-text-muted));
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.1em; letter-spacing: 0.1em;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
@@ -658,7 +671,7 @@ onUnmounted(() => {
padding: 0.625rem 0.75rem; padding: 0.625rem 0.75rem;
border-radius: 0.5rem; border-radius: 0.5rem;
font-size: 0.85rem; font-size: 0.85rem;
color: hsl(20 8% 55%); color: hsl(var(--color-text-muted));
background: transparent; background: transparent;
border: none; border: none;
cursor: pointer; cursor: pointer;
@@ -667,7 +680,7 @@ onUnmounted(() => {
width: 100%; width: 100%;
} }
.sommaire-item:hover { .sommaire-item:hover {
background: hsl(20 8% 12%); background: hsl(var(--color-surface));
color: white; color: white;
} }
.sommaire-item--active { .sommaire-item--active {
@@ -683,8 +696,8 @@ onUnmounted(() => {
font-size: 0.65rem; font-size: 0.65rem;
font-weight: 700; font-weight: 700;
font-family: var(--font-mono, monospace); font-family: var(--font-mono, monospace);
color: hsl(20 8% 40%); color: hsl(var(--color-text-muted));
background: hsl(20 8% 12%); background: hsl(var(--color-surface));
flex-shrink: 0; flex-shrink: 0;
} }
.sommaire-item--active .sommaire-num { .sommaire-item--active .sommaire-num {
@@ -713,7 +726,7 @@ onUnmounted(() => {
width: 100%; width: 100%;
overflow: hidden auto; overflow: hidden auto;
border-radius: 0.75rem; border-radius: 0.75rem;
background: hsl(20 8% 5% / 0.4); background: hsl(var(--color-bg) / 0.4);
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
} }
@@ -809,7 +822,7 @@ onUnmounted(() => {
height: 100%; height: 100%;
pointer-events: none; pointer-events: none;
opacity: 0; opacity: 0;
background: linear-gradient(to left, hsl(20 8% 3% / 0.4), transparent); background: linear-gradient(to left, hsl(var(--color-bg) / 0.4), transparent);
transition: opacity 0.15s; transition: opacity 0.15s;
border-radius: 0 0.75rem 0.75rem 0; border-radius: 0 0.75rem 0.75rem 0;
} }
@@ -833,7 +846,7 @@ onUnmounted(() => {
width: 2.5rem; height: 2.5rem; width: 2.5rem; height: 2.5rem;
border-radius: 50%; border-radius: 50%;
background: transparent; background: transparent;
color: hsl(20 8% 45%); color: hsl(var(--color-text-muted));
border: none; border: none;
cursor: pointer; cursor: pointer;
transition: all 0.3s; transition: all 0.3s;
@@ -861,7 +874,7 @@ onUnmounted(() => {
border-radius: 50%; border-radius: 50%;
overflow: hidden; overflow: hidden;
flex-shrink: 0; flex-shrink: 0;
border: 2px solid hsl(20 8% 22%); border: 2px solid hsl(var(--color-surface-light));
box-shadow: 0 0 10px hsl(var(--scene-h1) 50% 40% / 0.15); box-shadow: 0 0 10px hsl(var(--scene-h1) 50% 40% / 0.15);
} }
.reader-disc.spinning { .reader-disc.spinning {
@@ -878,12 +891,12 @@ onUnmounted(() => {
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
width: 0.5rem; height: 0.5rem; width: 0.5rem; height: 0.5rem;
border-radius: 50%; border-radius: 50%;
background: hsl(20 8% 5%); background: hsl(var(--color-bg));
border: 1.5px solid hsl(20 8% 18%); border: 1.5px solid hsl(var(--color-surface-light));
} }
.reader-song-name { .reader-song-name {
font-size: 0.75rem; font-size: 0.75rem;
color: hsl(20 8% 50%); color: hsl(var(--color-text-muted));
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
+74 -4
View File
@@ -8,25 +8,39 @@
· {{ readingTime }} · {{ readingTime }}
</span> </span>
</div> </div>
<h1 class="chapter-title font-display font-bold leading-tight tracking-tight text-white"> <h1 class="chapter-title relative font-display font-bold leading-tight tracking-tight text-white">
{{ title }} {{ title }}
<!-- The calligrapher in the left margin, feet on the title's rule (rule e) -->
<UiOiseauPose nom="calligraphe" pose="marge-g" ecart="40px" :w="112" />
</h1> </h1>
<p v-if="description" class="mt-3 text-lg text-white/60"> <p v-if="description" class="mt-3 text-lg text-white/60">
{{ description }} {{ description }}
</p> </p>
<!-- Songs + PDF actions row. The box maker in the right margin, feet at the bottom of the
buttons (every chapter has its PDF page) -->
<div class="relative mt-4 flex flex-wrap items-center gap-2">
<UiOiseauPose nom="cartonnier" pose="marge-d" ecart="40px" :w="76" retourne />
<!-- Associated songs badges --> <!-- Associated songs badges -->
<div v-if="songs.length > 0" class="mt-4 flex flex-wrap gap-2">
<button <button
v-for="song in songs" v-for="song in songs"
:key="song.id" :key="song.id"
class="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary transition-colors hover:bg-primary/20" class="song-badge-btn"
@click="playSong(song)" @click="playSong(song)"
> >
<div class="i-lucide-music h-3 w-3" /> <div class="i-lucide-music h-3 w-3" />
{{ song.title }} {{ song.title }}
</button> </button>
<!-- PDF button -->
<button v-if="pdfPage" class="pdf-btn" @click="showPdf = true">
<div class="i-lucide-book-open h-3.5 w-3.5" />
<span>Lire dans le PDF</span>
<span class="pdf-page-badge">p.{{ pdfPage }}</span>
</button>
</div> </div>
<LazyBookPdfReader v-if="showPdf" v-model="showPdf" :page="pdfPage" />
</header> </header>
</template> </template>
@@ -44,9 +58,12 @@ const props = defineProps<{
const bookData = useBookData() const bookData = useBookData()
const { loadAndPlay } = useAudioPlayer() const { loadAndPlay } = useAudioPlayer()
await bookData.init() bookData.init()
const songs = computed(() => bookData.getChapterSongs(props.chapterSlug)) const songs = computed(() => bookData.getChapterSongs(props.chapterSlug))
const pdfPage = computed(() => bookData.getChapterPage(props.chapterSlug))
const showPdf = ref(false)
function playSong(song: Song) { function playSong(song: Song) {
loadAndPlay(song) loadAndPlay(song)
@@ -59,4 +76,57 @@ function playSong(song: Song) {
padding-bottom: 0.75rem; padding-bottom: 0.75rem;
border-bottom: 2px solid hsl(var(--color-primary) / 0.4); border-bottom: 2px solid hsl(var(--color-primary) / 0.4);
} }
.song-badge-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
border-radius: 9999px;
padding: 0.375rem 0.875rem;
font-size: 0.8rem;
font-weight: 600;
background: hsl(var(--color-primary) / 0.12);
color: hsl(var(--color-primary) / 0.85);
border: 1px solid hsl(var(--color-primary) / 0.25);
cursor: pointer;
transition: all 0.2s;
}
.song-badge-btn:hover {
background: hsl(var(--color-primary) / 0.2);
border-color: hsl(var(--color-primary) / 0.4);
box-shadow: 0 2px 8px hsl(var(--color-primary) / 0.15);
transform: translateY(-1px);
}
.pdf-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
background: hsl(0 0% 96%);
color: hsl(0 0% 15%);
font-size: 0.8rem;
font-weight: 600;
border: 1px solid hsl(0 0% 88%);
cursor: pointer;
transition: all 0.2s;
}
.pdf-btn:hover {
background: white;
box-shadow: 0 2px 12px hsl(0 0% 100% / 0.25);
transform: translateY(-1px);
}
.pdf-page-badge {
font-family: var(--font-mono, monospace);
font-size: 0.7rem;
font-weight: 500;
padding: 0.1rem 0.4rem;
border-radius: 0.25rem;
background: hsl(0 0% 88%);
color: hsl(0 0% 35%);
}
</style> </style>
+1 -1
View File
@@ -6,7 +6,7 @@
<ul class="flex flex-col gap-1"> <ul class="flex flex-col gap-1">
<li v-for="chapter in chapters" :key="chapter.path"> <li v-for="chapter in chapters" :key="chapter.path">
<NuxtLink <NuxtLink
:to="`/modele-eco/${chapter.stem?.split('/').pop()}`" :to="`/economique/modele-eco/${chapter.stem?.split('/').pop()}`"
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors hover:bg-white/5" class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors hover:bg-white/5"
active-class="bg-primary/10 text-primary font-medium" active-class="bg-primary/10 text-primary font-medium"
> >
+159
View File
@@ -0,0 +1,159 @@
<template>
<div class="chapter-toc" ref="tocRoot">
<button class="toc-toggle" @click="open = !open" :aria-expanded="open">
<div class="i-lucide-list h-4 w-4" />
<span>Sommaire</span>
<div class="i-lucide-chevron-down h-3.5 w-3.5 toc-chevron" :class="{ 'toc-chevron--open': open }" />
</button>
<Transition name="toc-dropdown">
<div v-if="open" class="toc-panel">
<nav>
<a
v-for="item in headings"
:key="item.id"
:href="`#${item.id}`"
class="toc-item"
:class="{ 'toc-item--h2': item.level === 2 }"
@click="open = false"
>
{{ item.text }}
</a>
</nav>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
interface TocHeading {
id: string
text: string
level: number
}
const open = ref(false)
const headings = ref<TocHeading[]>([])
const tocRoot = ref<HTMLElement>()
function extractHeadings() {
const article = document.querySelector('.prose-lyrics')
if (!article) return
const nodes = article.querySelectorAll('h1, h2')
headings.value = Array.from(nodes)
.filter(el => el.id)
.map(el => ({
id: el.id,
text: el.textContent?.trim() ?? '',
level: parseInt(el.tagName[1]),
}))
}
// Close on click outside
function onClickOutside(e: MouseEvent) {
if (tocRoot.value && !tocRoot.value.contains(e.target as Node)) {
open.value = false
}
}
onMounted(() => {
// Wait for content to render
nextTick(() => {
extractHeadings()
// Retry after a short delay in case content loads async
setTimeout(extractHeadings, 500)
})
document.addEventListener('click', onClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', onClickOutside)
})
</script>
<style scoped>
.chapter-toc {
position: relative;
display: inline-block;
}
.toc-toggle {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
background: hsl(20 8% 10%);
border: 1px solid hsl(20 8% 18%);
color: hsl(20 8% 60%);
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.toc-toggle:hover {
border-color: hsl(20 8% 30%);
color: white;
}
.toc-chevron {
transition: transform 0.2s;
}
.toc-chevron--open {
transform: rotate(180deg);
}
.toc-panel {
position: absolute;
top: calc(100% + 0.5rem);
left: 0;
z-index: 30;
min-width: 280px;
max-width: 400px;
max-height: 60vh;
overflow-y: auto;
padding: 0.5rem;
border-radius: 0.5rem;
background: hsl(20 8% 8%);
border: 1px solid hsl(20 8% 16%);
box-shadow: 0 8px 32px hsl(0 0% 0% / 0.4);
}
.toc-item {
display: block;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.8rem;
color: hsl(20 8% 65%);
text-decoration: none;
transition: all 0.15s;
line-height: 1.3;
}
.toc-item:hover {
background: hsl(20 8% 12%);
color: white;
}
.toc-item--h2 {
padding-left: 1.5rem;
font-size: 0.75rem;
color: hsl(20 8% 50%);
}
/* Dropdown transition */
.toc-dropdown-enter-active {
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
.toc-dropdown-leave-active {
transition: all 0.15s ease-in;
}
.toc-dropdown-enter-from,
.toc-dropdown-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>
+2 -3
View File
@@ -131,10 +131,9 @@ const { data: content } = await usePageContent('home')
} }
.book-cover-img { .book-cover-img {
width: 200%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: contain;
transform: translateX(-50%);
} }
.heading-section { .heading-section {
+58 -67
View File
@@ -1,63 +1,47 @@
<template> <template>
<section class="section-padding"> <section :class="compact ? 'section-book-compact' : 'section-padding'">
<div class="container-content"> <div class="container-content">
<div class="grid items-center gap-12 md:grid-cols-2"> <div :class="['grid items-center', compact ? 'gap-5 md:grid-cols-[auto_1fr]' : 'gap-12 md:grid-cols-2']">
<!-- Book cover --> <!-- Book cover -->
<UiScrollReveal> <UiScrollReveal>
<div class="book-cover-wrapper relative"> <div class="book-cover-wrapper relative">
<!-- Shadok pumper --> <div
<svg class="shadok-pumper" viewBox="0 0 200 240" fill="none" aria-hidden="true"> :class="['book-cover-3d', compact && 'book-cover-3d--compact']"
<ellipse cx="100" cy="130" rx="55" ry="65" fill="currentColor" opacity="0.9"/> :style="{ backgroundImage: `url(${content?.book.coverImage})` }"
<ellipse cx="100" cy="60" rx="30" ry="28" fill="currentColor" opacity="0.85"/> role="img"
<circle cx="88" cy="54" r="6" fill="currentColor" opacity="0.2"/> :aria-label="content?.book.coverAlt"
<circle cx="112" cy="54" r="6" fill="currentColor" opacity="0.2"/>
<circle cx="90" cy="53" r="2.5" fill="currentColor" opacity="0.5"/>
<circle cx="114" cy="53" r="2.5" fill="currentColor" opacity="0.5"/>
<polygon points="100,68 115,78 85,78" fill="currentColor" opacity="0.6"/>
<line x1="80" y1="192" x2="70" y2="230" stroke="currentColor" stroke-width="4" stroke-linecap="round" opacity="0.7"/>
<line x1="120" y1="192" x2="130" y2="230" stroke="currentColor" stroke-width="4" stroke-linecap="round" opacity="0.7"/>
<line x1="70" y1="230" x2="55" y2="232" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.5"/>
<line x1="130" y1="230" x2="145" y2="232" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.5"/>
<line x1="155" y1="110" x2="190" y2="90" stroke="currentColor" stroke-width="4" stroke-linecap="round" opacity="0.6"/>
<line x1="190" y1="90" x2="190" y2="120" stroke="currentColor" stroke-width="4" stroke-linecap="round" opacity="0.6"/>
<rect x="180" y="118" width="18" height="40" rx="3" fill="currentColor" opacity="0.4"/>
</svg>
<div class="book-cover-3d">
<img
:src="content?.book.coverImage"
:alt="content?.book.coverAlt"
class="book-cover-img"
/> />
</div> </div>
</div>
</UiScrollReveal> </UiScrollReveal>
<!-- Content + CTAs --> <!-- Content + CTAs -->
<div> <div>
<UiScrollReveal> <UiScrollReveal>
<p class="mb-2 font-mono text-sm tracking-widest text-accent uppercase">{{ content?.book.kicker }}</p> <p :class="['font-mono tracking-widest text-accent uppercase', compact ? 'text-xs mb-1' : 'text-sm mb-2']">
<h2 class="heading-section font-display font-bold tracking-tight text-white"> {{ content?.book.kicker }}
{{ content?.book.title }} </p>
<h2 :class="['book-heading font-display font-bold tracking-tight text-white', compact && 'book-heading--compact']">
<template v-if="compact">
<span class="block">{{ titleLine1 }}</span>
<span class="block" style="color: hsl(var(--color-text) / 0.55)">{{ titleLine2 }}</span>
</template>
<template v-else>{{ content?.book.title }}</template>
</h2> </h2>
</UiScrollReveal> </UiScrollReveal>
<UiScrollReveal :delay="100"> <UiScrollReveal :delay="100">
<p class="mt-4 text-lg leading-relaxed text-white/60"> <p :class="['leading-relaxed', compact ? 'mt-2 text-sm' : 'mt-4 text-lg text-white/60']"
:style="compact ? 'color: hsl(var(--color-text-muted)); font-size: 0.85rem' : ''">
{{ content?.book.description }} {{ content?.book.description }}
</p> </p>
</UiScrollReveal> </UiScrollReveal>
<UiScrollReveal :delay="200"> <UiScrollReveal :delay="200">
<div class="mt-8 flex flex-col gap-3 sm:flex-row sm:gap-4"> <BookActions
<UiBaseButton @click="$emit('open-player')"> :show-chapters="showChapters"
<div class="i-lucide-play mr-2 h-5 w-5" /> @open-player="$emit('open-player')"
{{ content?.book.cta.player }} @open-pdf="$emit('open-pdf')"
</UiBaseButton> />
<UiBaseButton variant="accent" @click="$emit('open-pdf')">
<div class="i-lucide-book-open mr-2 h-5 w-5" />
{{ content?.book.cta.pdf }}
</UiBaseButton>
</div>
</UiScrollReveal> </UiScrollReveal>
</div> </div>
</div> </div>
@@ -66,15 +50,36 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
withDefaults(defineProps<{
showChapters?: boolean
compact?: boolean
}>(), {
showChapters: true,
compact: false,
})
defineEmits<{ defineEmits<{
'open-player': [] 'open-player': []
'open-pdf': [] 'open-pdf': []
}>() }>()
const { data: content } = await usePageContent('home') const { data: content } = await usePageContent('home')
const titleParts = computed(() => {
const title = content.value?.book.title ?? ''
const idx = title.indexOf('—')
if (idx === -1) return [title, '']
return [title.slice(0, idx).trim(), '— ' + title.slice(idx + 1).trim()]
})
const titleLine1 = computed(() => titleParts.value[0])
const titleLine2 = computed(() => titleParts.value[1])
</script> </script>
<style scoped> <style scoped>
.section-book-compact {
padding: 0;
}
.book-cover-wrapper { .book-cover-wrapper {
perspective: 800px; perspective: 800px;
display: flex; display: flex;
@@ -82,7 +87,12 @@ const { data: content } = await usePageContent('home')
} }
.book-cover-3d { .book-cover-3d {
aspect-ratio: 3 / 4; width: 100%;
max-width: 360px;
aspect-ratio: 1756 / 2480;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
border-radius: 0.75rem; border-radius: 0.75rem;
overflow: hidden; overflow: hidden;
border: 1px solid hsl(var(--color-text) / 0.1); border: 1px solid hsl(var(--color-text) / 0.1);
@@ -91,7 +101,10 @@ const { data: content } = await usePageContent('home')
0 0 0 1px hsl(var(--color-text) / 0.08); 0 0 0 1px hsl(var(--color-text) / 0.08);
transition: transform 0.5s cubic-bezier(0.645, 0.045, 0.355, 1), transition: transform 0.5s cubic-bezier(0.645, 0.045, 0.355, 1),
box-shadow 0.5s ease; box-shadow 0.5s ease;
max-width: 360px; }
.book-cover-3d--compact {
max-width: 120px;
} }
.book-cover-3d:hover { .book-cover-3d:hover {
@@ -101,35 +114,13 @@ const { data: content } = await usePageContent('home')
0 0 0 1px hsl(var(--color-primary) / 0.2); 0 0 0 1px hsl(var(--color-primary) / 0.2);
} }
.book-cover-img {
width: 200%;
height: 100%;
object-fit: cover;
transform: translateX(-50%);
}
.heading-section { .book-heading {
font-size: clamp(1.625rem, 4vw, 2.125rem); font-size: clamp(1.625rem, 4vw, 2.125rem);
} }
.shadok-pumper { .book-heading--compact {
position: absolute; font-size: clamp(0.95rem, 2.5vw, 1.15rem);
right: 3%; line-height: 1.35;
bottom: 8%;
width: clamp(90px, 12vw, 180px);
opacity: 0.28;
pointer-events: none;
color: hsl(var(--color-primary));
animation: shadok-float 10s ease-in-out infinite;
z-index: 1;
}
@keyframes shadok-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
@media (max-width: 768px) {
.shadok-pumper { display: none; }
} }
</style> </style>
-110
View File
@@ -1,110 +0,0 @@
<template>
<section class="section-padding">
<div class="container-content">
<UiScrollReveal>
<div class="gw-card relative overflow-hidden">
<!-- Shadok blob -->
<svg class="shadok-blob" viewBox="0 0 200 180" fill="none" aria-hidden="true">
<path d="M60 90 Q30 50 70 30 Q110 10 140 40 Q180 60 170 100 Q165 140 130 155 Q90 170 55 145 Q25 125 60 90Z" fill="currentColor" opacity="0.12"/>
<path d="M60 90 Q30 50 70 30 Q110 10 140 40 Q180 60 170 100 Q165 140 130 155 Q90 170 55 145 Q25 125 60 90Z" stroke="currentColor" stroke-width="1.5" opacity="0.2"/>
<circle cx="100" cy="80" r="8" fill="currentColor" opacity="0.08"/>
<circle cx="120" cy="110" r="6" fill="currentColor" opacity="0.06"/>
<circle cx="80" cy="105" r="5" fill="currentColor" opacity="0.07"/>
<circle cx="95" cy="72" r="3" fill="currentColor" opacity="0.3"/>
<circle cx="108" cy="70" r="3" fill="currentColor" opacity="0.3"/>
<circle cx="96" cy="71" r="1.2" fill="currentColor" opacity="0.5"/>
<circle cx="109" cy="69" r="1.2" fill="currentColor" opacity="0.5"/>
</svg>
<div class="flex flex-col items-center text-center gap-4 md:flex-row md:text-left md:gap-8 relative z-1">
<!-- Icon -->
<div class="gw-icon-wrapper">
<div class="i-lucide-sparkles h-8 w-8 text-amber-400" />
</div>
<!-- Content -->
<div class="flex-1">
<span class="inline-block mb-2 rounded-full bg-amber-400/15 px-3 py-0.5 font-mono text-xs tracking-widest text-amber-400 uppercase">
{{ content?.grateWizardTeaser.kicker }}
</span>
<h3 class="heading-h3 font-display font-bold text-white">
{{ content?.grateWizardTeaser.title }}
</h3>
<p class="mt-2 text-sm text-white/60 md:text-base leading-relaxed">
{{ content?.grateWizardTeaser.description }}
</p>
</div>
<!-- CTAs -->
<div class="shrink-0 flex flex-col gap-2">
<UiBaseButton :href="url" target="_blank" @click="launch">
<div class="i-lucide-external-link mr-2 h-4 w-4" />
{{ content?.grateWizardTeaser.cta.launch }}
</UiBaseButton>
<UiBaseButton variant="ghost" :to="content?.grateWizardTeaser.cta.more.to">
{{ content?.grateWizardTeaser.cta.more.label }}
<div class="i-lucide-arrow-right ml-2 h-4 w-4" />
</UiBaseButton>
</div>
</div>
</div>
</UiScrollReveal>
</div>
</section>
</template>
<script setup lang="ts">
const { url, launch } = useGrateWizard()
const { data: content } = await usePageContent('home')
</script>
<style scoped>
.gw-card {
border: 1px solid hsl(40 80% 50% / 0.2);
border-radius: 1rem;
padding: 1.5rem 2rem;
background: linear-gradient(135deg, hsl(40 80% 50% / 0.05), hsl(40 80% 50% / 0.02));
box-shadow: 0 0 40px hsl(40 80% 50% / 0.05);
transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
.gw-card:hover {
border-color: hsl(40 80% 50% / 0.35);
box-shadow: 0 0 60px hsl(40 80% 50% / 0.1);
}
.heading-h3 {
font-size: clamp(1.25rem, 3vw, 1.625rem);
}
.gw-icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 3.5rem;
height: 3.5rem;
border-radius: 0.75rem;
background: hsl(40 80% 50% / 0.1);
border: 1px solid hsl(40 80% 50% / 0.15);
flex-shrink: 0;
}
.shadok-blob {
position: absolute;
right: -2%;
top: -20%;
width: clamp(120px, 16vw, 220px);
opacity: 0.35;
pointer-events: none;
color: hsl(var(--color-accent));
animation: shadok-drift 12s ease-in-out infinite;
}
@keyframes shadok-drift {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-8px) rotate(3deg); }
}
@media (max-width: 768px) {
.shadok-blob { display: none; }
}
</style>
-134
View File
@@ -1,134 +0,0 @@
<template>
<section class="relative overflow-hidden section-padding">
<!-- Background gradient -->
<div class="absolute inset-0 bg-gradient-to-b from-primary/10 via-transparent to-surface-bg" />
<div class="absolute inset-0 bg-[radial-gradient(ellipse_at_top,hsl(12_76%_48%/0.15),transparent_70%)]" />
<!-- Shadok bird decoration -->
<svg class="shadok-bird" viewBox="0 0 180 260" fill="none" aria-hidden="true">
<ellipse cx="90" cy="100" rx="45" ry="40" fill="currentColor" opacity="0.85"/>
<circle cx="130" cy="60" r="22" fill="currentColor" opacity="0.8"/>
<path d="M110 85 Q125 70 128 63" stroke="currentColor" stroke-width="8" stroke-linecap="round" opacity="0.7" fill="none"/>
<circle cx="136" cy="55" r="5" fill="currentColor" opacity="0.3"/>
<circle cx="137" cy="54" r="2" fill="currentColor" opacity="0.6"/>
<polygon points="150,58 175,50 152,65" fill="currentColor" opacity="0.6"/>
<line x1="75" y1="138" x2="60" y2="230" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<line x1="105" y1="138" x2="115" y2="230" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<circle cx="66" cy="190" r="4" fill="currentColor" opacity="0.4"/>
<circle cx="111" cy="190" r="4" fill="currentColor" opacity="0.4"/>
<path d="M60 230 L45 233 M60 230 L55 236 M60 230 L65 235" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/>
<path d="M115 230 L100 233 M115 230 L110 236 M115 230 L120 235" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/>
<path d="M48 95 Q20 80 15 65" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.5" fill="none"/>
<path d="M48 100 Q22 92 10 85" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4" fill="none"/>
<path d="M48 105 Q25 102 12 100" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.3" fill="none"/>
</svg>
<!-- Shadok boulanger: character with oven and bread -->
<svg class="shadok-boulanger" viewBox="0 0 240 300" fill="none" aria-hidden="true">
<ellipse cx="120" cy="155" rx="40" ry="48" fill="currentColor" opacity="0.85"/>
<circle cx="120" cy="92" r="25" fill="currentColor" opacity="0.8"/>
<ellipse cx="120" cy="68" rx="18" ry="22" fill="currentColor" opacity="0.35"/>
<rect x="105" y="78" width="30" height="5" rx="1" fill="currentColor" opacity="0.4"/>
<path d="M110 88 Q114 84 118 88" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.4"/>
<path d="M124 88 Q128 84 132 88" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.4"/>
<path d="M112 102 Q120 108 128 102" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.35"/>
<line x1="160" y1="145" x2="190" y2="135" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<rect x="185" y="125" width="40" height="10" rx="5" fill="currentColor" opacity="0.4" transform="rotate(-15 205 130)"/>
<line x1="80" y1="148" x2="55" y2="175" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<line x1="105" y1="200" x2="95" y2="258" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<line x1="135" y1="200" x2="145" y2="258" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<rect x="30" y="180" width="50" height="40" rx="4" fill="currentColor" opacity="0.25"/>
<rect x="35" y="185" width="40" height="20" rx="2" fill="currentColor" opacity="0.15"/>
<ellipse cx="55" cy="195" rx="12" ry="6" fill="currentColor" opacity="0.12"/>
</svg>
<!-- Content -->
<div class="container-content relative z-10 px-4">
<div class="mx-auto max-w-3xl text-center">
<UiScrollReveal>
<p class="mb-3 font-mono text-sm tracking-widest text-primary uppercase">
{{ content?.hero.kicker }}
</p>
</UiScrollReveal>
<UiScrollReveal :delay="100">
<h1 class="font-display font-extrabold leading-tight tracking-tight">
<span class="hero-title text-gradient">{{ content?.hero.title }}</span>
</h1>
</UiScrollReveal>
<UiScrollReveal :delay="200">
<p class="mt-6 text-lg leading-relaxed text-white/60 md:text-xl">
{{ content?.hero.subtitle }}
</p>
</UiScrollReveal>
<UiScrollReveal :delay="300">
<p class="mt-4 text-base leading-relaxed text-white/45">
{{ content?.hero.footnote }}
</p>
</UiScrollReveal>
<UiScrollReveal :delay="400">
<div class="mt-8 flex justify-center">
<UiBaseButton variant="ghost" :to="content?.hero.cta.to">
{{ content?.hero.cta.label }}
<div class="i-lucide-arrow-right ml-2 h-4 w-4" />
</UiBaseButton>
</div>
</UiScrollReveal>
<HomeMessages />
</div>
</div>
</section>
</template>
<script setup lang="ts">
const { data: content } = await usePageContent('home')
</script>
<style scoped>
.hero-title {
font-size: clamp(2.25rem, 7vw, 4rem);
}
.shadok-bird {
position: absolute;
right: 4%;
top: 12%;
width: clamp(110px, 15vw, 220px);
opacity: 0.3;
pointer-events: none;
color: hsl(var(--color-primary));
animation: shadok-float 8s ease-in-out infinite;
z-index: 1;
}
@keyframes shadok-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-12px); }
}
.shadok-boulanger {
position: absolute;
left: 3%;
bottom: 8%;
width: clamp(100px, 13vw, 190px);
opacity: 0.25;
pointer-events: none;
color: hsl(var(--color-accent));
animation: shadok-float-boulanger 9s ease-in-out infinite;
z-index: 1;
}
@keyframes shadok-float-boulanger {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
@media (max-width: 768px) {
.shadok-bird { display: none; }
.shadok-boulanger { display: none; }
}
</style>
+42
View File
@@ -0,0 +1,42 @@
<template>
<blockquote v-if="lines?.length" class="cites">
<p v-for="(line, i) in lines" :key="i" class="cite" :class="{ 'cite--lead': i === 0 }">{{ line }}</p>
</blockquote>
</template>
<script setup lang="ts">
defineProps<{
lines: string[]
}>()
</script>
<style scoped>
.cites {
margin: 0;
padding: 0;
max-width: 46rem;
}
.cite {
margin: 0;
font-family: var(--font-display);
font-weight: 500;
font-size: clamp(1.15rem, 2.4vw, 1.55rem);
line-height: 1.3;
letter-spacing: -0.015em;
color: hsl(var(--color-text) / 0.62);
}
.cite + .cite {
margin-top: 0.3rem;
}
.cite--lead {
font-weight: 800;
font-size: clamp(1.6rem, 3.6vw, 2.3rem);
line-height: 1.1;
letter-spacing: -0.03em;
color: hsl(var(--color-text));
margin-bottom: 0.6rem;
}
</style>
+708
View File
@@ -0,0 +1,708 @@
<template>
<div
ref="root"
class="console"
:class="{ 'console--compact': compact, 'console--still': reduced, 'console--hand': engaged }"
@pointerenter="pointerIn = true"
@pointerleave="pointerIn = false"
@focusin="focusIn = true"
@focusout="onFocusOut"
>
<div class="console-board">
<!-- Scale, read bottom to top: from asservissement to émancipation -->
<div class="console-scale" aria-hidden="true">
<span class="console-scale-bottom">{{ bottomLabel }}</span>
<span class="console-scale-line" />
<span class="console-scale-top">{{ topLabel }}</span>
</div>
<div
class="console-faders"
role="tablist"
:aria-label="`Les trois axes — ${bottomLabel} ou ${topLabel}`"
@keydown="onKeydown"
>
<button
v-for="(axis, i) in axes"
:id="`fader-${uid}-${axis.key}`"
:key="axis.key"
ref="tabs"
type="button"
role="tab"
class="fader"
:class="{ 'fader--active': axis.key === active, 'fader--held': held.has(axis.key), 'fader--rising': rising.has(axis.key), 'fader--stopped': stopped.has(axis.key) }"
:style="{ '--hue': `var(${axis.hue})`, '--level': levels[axis.key] ?? 0, '--i': i }"
:data-axis="axis.key"
:aria-selected="axis.key === active"
:aria-controls="`panel-${uid}`"
:tabindex="axis.key === active ? 0 : -1"
@pointerdown="lastPointer = $event.pointerType"
@pointerenter="onEnter($event, axis.key)"
@pointerleave="hoverKey = null"
@focus="onFocus($event, axis.key)"
@click="onClick($event, axis.key)"
>
<span class="fader-track" aria-hidden="true">
<span class="fader-fill" />
<span class="fader-cap" />
</span>
<span class="fader-label">
<span class="fader-prefix">{{ prefix }}</span>
<span class="fader-name">{{ axis.short }}</span>
</span>
<span class="fader-lever">{{ axis.lever }}</span>
</button>
</div>
</div>
<div
v-if="current"
:id="`panel-${uid}`"
role="tabpanel"
class="console-panel"
:style="{ '--hue': `var(${current.hue})` }"
:aria-labelledby="`fader-${uid}-${current.key}`"
>
<Transition name="panel" mode="out-in">
<div :key="current.key" class="panel-inner">
<p class="panel-upstream">
<span class="panel-upstream-label">{{ upstreamLabel }}</span>
{{ current.lever }}
</p>
<h2 class="panel-title">{{ current.title }}</h2>
<p v-if="current.promise" class="panel-promise">{{ current.promise }}</p>
<ul class="panel-tools">
<li v-for="tool in current.items" :key="tool.label">
<NuxtLink v-if="tool.to" :to="tool.to" class="panel-tool">
<span class="panel-tool-name">{{ tool.label }}</span>
<span v-if="tool.presentation" class="panel-tool-app">{{ tool.presentation.title }}</span>
<span v-if="tool.status || tool.gestation" class="panel-tool-state">{{ (tool.status || 'En gestation').toLowerCase() }}</span>
</NuxtLink>
</li>
</ul>
<p v-if="current.key === 'numerique' && stacks?.length" class="panel-stacks">
<span class="panel-stacks-label">{{ stacksLabel }}</span>
<span v-for="s in stacks.slice(0, maxStacks)" :key="s.name" class="panel-stack">{{ s.name }}</span>
<NuxtLink v-if="stacks.length > maxStacks && stacksTo" :to="stacksTo" class="panel-stacks-more">
+{{ stacks.length - maxStacks }}
</NuxtLink>
</p>
<NuxtLink :to="current.to" class="panel-more">
Voir l'axe {{ current.short }}
<span class="i-lucide-arrow-right h-4 w-4" aria-hidden="true" />
</NuxtLink>
</div>
</Transition>
</div>
</div>
</template>
<script setup lang="ts">
import type { AxisKey, HomeAxis, HomeStack } from '~/composables/useHomeData'
const props = withDefaults(defineProps<{
axes: HomeAxis[]
prefix?: string
topLabel?: string
bottomLabel?: string
upstreamLabel?: string
stacks?: HomeStack[]
stacksLabel?: string
stacksTo?: string
maxStacks?: number
compact?: boolean
}>(), {
prefix: 'Autonomie',
topLabel: 'Émancipation',
bottomLabel: 'Asservissement',
maxStacks: 8,
upstreamLabel: 'en amont',
stacksLabel: 'En service',
compact: false,
})
const active = defineModel<AxisKey>({ default: 'numerique' })
const uid = useId()
const root = ref<HTMLElement | null>(null)
const tabs = ref<HTMLButtonElement[]>([])
const current = computed(() => props.axes.find(a => a.key === active.value) ?? props.axes[0])
// ── Motion ──────────────────────────────────────────────────────────────
// Yvv's instructions, word for word: docs/consignes-yvv.md (29/09 and 03/11).
//
// Before any gesture (29/09): the cursors move on their own, slowly; the low
// ones drift, and every few beats another axis is pushed up to its own
// graduation (03/11), the level the hand gives it.
// SSR renders every cursor at the bottom, so the first move is the rise.
//
// The visitor's hand takes over for good (03/11): from the first hover,
// keyboard focus or tap on a cursor, the rotation stops. The touched cursor
// replays its rise, from the bottom to its own graduation, and its panel
// shows. The others are never lowered by the hand (orch decision, 03/11
// 13h10): a cursor already touched stays at its graduation; a cursor never
// touched that the rotation was taking down stops where it is at the first
// gesture; one it was taking up finishes, gently, its rise to the level the
// rotation gave it, then stays there.
const BEAT_MS = 2600
const BEATS_PER_AXIS = 3
/** Graduation of each axis, from asservissement (0) to émancipation (1) */
const GRADUATION: Record<AxisKey, number> = { numerique: 3 / 4, economie: 1 / 2, politique: 2 / 3 }
/** The focus and the click of one tap, within this delay, give one rise */
const SAME_GESTURE_MS = 500
const levels = reactive<Partial<Record<AxisKey, number>>>({})
/** Cursors the hand has touched: they no longer glide with the rotation */
const held = reactive(new Set<AxisKey>())
/** Cursors replaying their rise (fast transition) */
const rising = reactive(new Set<AxisKey>())
/** Untouched cursors the first gesture stopped on their way down */
const stopped = reactive(new Set<AxisKey>())
const reduced = ref(false)
const visible = ref(true)
const engaged = ref(false)
const pointerIn = ref(false)
const focusIn = ref(false)
const hoverKey = ref<AxisKey | null>(null)
let lastPointer = ''
/** The tab the arrow keys are moving the focus to: its focus is the same gesture */
let keyFocus: AxisKey | null = null
/** When a keyboard focus last raised each cursor (to pair it with the click of a tap) */
const focusRiseAt: Partial<Record<AxisKey, number>> = {}
const riseRun: Partial<Record<AxisKey, number>> = {}
let beat = 0
let timer: ReturnType<typeof setInterval> | undefined
function place() {
props.axes.forEach((axis, i) => {
// The axis put forward goes up to its own graduation, as under the hand
if (axis.key === active.value) {
levels[axis.key] = GRADUATION[axis.key] ?? 0.75
return
}
levels[axis.key] = reduced.value ? 0.3 : 0.3 + 0.14 * Math.sin(beat * 0.9 + i * 2.1)
})
}
function faderEl(key: AxisKey) {
return root.value?.querySelector<HTMLElement>(`[data-axis="${key}"]`) ?? null
}
/** First gesture: the rotation stops for good. An untouched cursor on its way down stops
* where it is drawn; one on its way up finishes its glide to the target the rotation gave it.
* `touched` is the cursor of this gesture: it replays its rise anyway */
function engage(touched?: AxisKey) {
if (engaged.value) return
engaged.value = true
clearInterval(timer)
for (const axis of props.axes) {
const key = axis.key
if (key === touched || held.has(key)) continue
const el = faderEl(key)
const drawn = el ? Number.parseFloat(getComputedStyle(el).getPropertyValue('--level')) : Number.NaN
const target = levels[key] ?? 0
if (Number.isFinite(drawn) && drawn > target) {
stopped.add(key)
levels[key] = drawn
}
}
}
/** The touched cursor replays its rise: back to the bottom, then up to its graduation */
async function rise(key: AxisKey) {
held.add(key)
stopped.delete(key)
const target = GRADUATION[key] ?? 0.75
if (reduced.value) {
levels[key] = target
return
}
const run = (riseRun[key] ?? 0) + 1
riseRun[key] = run
rising.delete(key)
levels[key] = 0
await nextTick()
// Commit the bottom (no transition) before the rise starts from it
const el = faderEl(key)
if (el) getComputedStyle(el).getPropertyValue('--level')
if (riseRun[key] !== run) return
rising.add(key)
levels[key] = target
}
/** A cursor under the visitor's hand: it rises and its panel shows. Every rollover replays
* the rise, however soon after the previous one (Yvv, 03/11) */
function touch(key: AxisKey) {
engage(key)
active.value = key
rise(key)
}
/** The panel of a cursor already raised by this same gesture: no second rise */
function select(key: AxisKey) {
engage(key)
active.value = key
}
function onEnter(e: PointerEvent, key: AxisKey) {
// A finger scrolling over a cursor is not a rollover: taps go through the click
if (e.pointerType === 'touch') return
hoverKey.value = key
touch(key)
}
function isKeyboardFocus(el: HTMLElement) {
try {
return el.matches(':focus-visible')
}
catch {
return true
}
}
function onFocus(e: FocusEvent, key: AxisKey) {
// The arrow key that moved the focus here already raised the cursor
if (keyFocus === key) return
// Keyboard focus is a rollover; a focus from the mouse or a tap is handled elsewhere
if (hoverKey.value === key || !isKeyboardFocus(e.currentTarget as HTMLElement)) return
focusRiseAt[key] = Date.now()
touch(key)
}
function onClick(e: MouseEvent, key: AxisKey) {
const pointer = (e as PointerEvent).pointerType || lastPointer
lastPointer = ''
// A tap is a rollover: one rise per tap, even when the browser also gave it a keyboard focus
const tapFocused = Date.now() - (focusRiseAt[key] ?? 0) < SAME_GESTURE_MS
if (pointer === 'touch' && !tapFocused) return touch(key)
// A click with no hover nor focus before it (a screen reader, a script) raises the cursor
// with its panel; after a hover or a keyboard focus, the cursor is already up
if (hoverKey.value !== key && !held.has(key)) return touch(key)
select(key)
}
function onFocusOut(e: FocusEvent) {
focusIn.value = !!root.value?.contains(e.relatedTarget as Node | null)
}
function advance() {
const idx = props.axes.findIndex(a => a.key === active.value)
active.value = props.axes[(idx + 1) % props.axes.length]!.key
}
function onBeat() {
if (!visible.value || engaged.value) return
beat++
if (!pointerIn.value && !focusIn.value && beat % BEATS_PER_AXIS === 0) advance()
place()
}
watch(active, () => {
if (!engaged.value) place()
})
function onKeydown(e: KeyboardEvent) {
lastPointer = ''
const keys = ['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown', 'Home', 'End']
if (!keys.includes(e.key)) return
e.preventDefault()
// From the tab that has the focus: a hover may have selected another axis meanwhile
const from = (e.target as HTMLElement | null)?.closest<HTMLElement>('[data-axis]')?.dataset.axis
const idx = props.axes.findIndex(a => a.key === (from ?? active.value))
const last = props.axes.length - 1
let next = idx
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') next = idx === last ? 0 : idx + 1
if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') next = idx === 0 ? last : idx - 1
if (e.key === 'Home') next = 0
if (e.key === 'End') next = last
const key = props.axes[next]!.key
touch(key)
nextTick(() => {
// focus() fires the focus event at once: it is part of this key press, not a new rise
keyFocus = key
tabs.value[next]?.focus()
keyFocus = null
})
}
onMounted(() => {
reduced.value = window.matchMedia('(prefers-reduced-motion: reduce)').matches
requestAnimationFrame(place)
if (reduced.value) return
timer = setInterval(onBeat, BEAT_MS)
const io = new IntersectionObserver(([entry]) => { visible.value = !!entry?.isIntersecting })
if (root.value) io.observe(root.value)
onUnmounted(() => {
clearInterval(timer)
io.disconnect()
})
})
</script>
<style scoped>
.console {
--track-h: 11rem;
--cap-h: 1.25rem;
display: grid;
gap: 1.25rem;
}
.console--compact {
--track-h: 8rem;
}
/* ── Board ── */
.console-board {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.9rem;
padding: 1.25rem 1.25rem 1rem;
border-radius: 16px;
background: hsl(var(--color-surface));
box-shadow:
inset 0 1px 0 hsl(var(--color-text) / 0.04),
0 18px 40px -24px hsl(var(--color-text) / 0.35);
}
/* Vertical scale, read from bottom to top */
.console-scale {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
height: var(--track-h);
writing-mode: vertical-rl;
transform: rotate(180deg);
font-family: var(--font-display);
font-size: 0.72rem;
letter-spacing: 0.02em;
white-space: nowrap;
}
.console-scale-bottom {
color: hsl(var(--color-text) / 0.45);
}
.console-scale-top {
font-weight: 800;
color: hsl(var(--color-text));
}
.console-scale-line {
flex: 1;
width: 1px;
background: linear-gradient(to bottom, hsl(var(--color-text) / 0.12), hsl(var(--color-text) / 0.45));
}
.console-faders {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
/* ── Fader ── */
.fader {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.55rem;
padding: 0 0.25rem 0.25rem;
border-radius: 12px;
cursor: pointer;
/* --level is a registered property (main.css): the cursors glide */
transition: --level 2.6s cubic-bezier(0.45, 0.05, 0.55, 0.95);
transition-delay: calc(var(--i) * 160ms);
}
/* A cursor the hand has touched no longer glides: it drops to the bottom at once, then
replays its rise. At the first gesture, an untouched cursor on its way down stops where it
is; one on its way up finishes its glide, then the rotation is over */
.fader--held,
.fader--stopped {
transition: none;
}
.fader--held.fader--rising {
transition: --level 1.1s cubic-bezier(0.22, 1, 0.36, 1);
}
.fader:focus-visible {
outline: 2px solid hsl(var(--hue));
outline-offset: 2px;
}
.fader-track {
position: relative;
width: 0.5rem;
height: var(--track-h);
border-radius: 999px;
background: hsl(var(--color-text) / 0.1);
}
.fader-fill {
position: absolute;
inset: auto 0 0 0;
height: calc(var(--level) * (var(--track-h) - var(--cap-h)) + var(--cap-h) / 2);
border-radius: inherit;
background: hsl(var(--hue) / 0.45);
transition: background 0.6s ease;
}
.fader--active .fader-fill {
background: hsl(var(--hue));
}
.fader-cap {
position: absolute;
left: 50%;
bottom: 0;
width: 2.6rem;
height: var(--cap-h);
margin-left: -1.3rem;
border-radius: 6px;
background: hsl(var(--color-bg));
box-shadow:
0 1px 0 hsl(var(--color-text) / 0.08),
0 4px 10px -2px hsl(var(--color-text) / 0.3);
transform: translateY(calc(-1 * var(--level) * (var(--track-h) - var(--cap-h))));
}
/* Cap groove */
.fader-cap::after {
content: '';
position: absolute;
left: 0.45rem;
right: 0.45rem;
top: 50%;
height: 2px;
margin-top: -1px;
border-radius: 2px;
background: hsl(var(--hue));
}
.fader-label {
display: flex;
flex-direction: column;
align-items: center;
line-height: 1.1;
}
.fader-prefix {
font-family: var(--font-mono);
font-size: 0.6rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: hsl(var(--color-text) / 0.5);
}
.fader-name {
font-family: var(--font-display);
font-weight: 800;
font-size: 1.05rem;
letter-spacing: -0.01em;
color: hsl(var(--color-text) / 0.65);
transition: color 0.6s ease;
}
.fader--active .fader-name {
color: hsl(var(--hue));
}
.fader-lever {
padding: 0.2rem 0.55rem;
border-radius: 20px;
font-family: var(--font-display);
font-weight: 600;
font-size: 0.8rem;
line-height: 1.2;
text-align: center;
color: hsl(var(--color-text) / 0.8);
background: hsl(var(--hue) / 0.1);
transition: background 0.6s ease, color 0.6s ease;
}
.fader--active .fader-lever {
color: hsl(var(--color-bg));
background: hsl(var(--hue));
}
/* ── Panel ── */
.console-panel {
position: relative;
padding: 1.25rem 1.4rem 1.2rem;
border-radius: 16px;
background: hsl(var(--hue) / 0.08);
}
.panel-upstream {
margin: 0 0 0.35rem;
font-family: var(--font-mono);
font-size: 0.72rem;
color: hsl(var(--hue));
}
.panel-upstream-label {
color: hsl(var(--color-text) / 0.45);
}
.panel-upstream-label::after {
content: ' ↑ ';
}
.panel-title {
margin: 0;
font-family: var(--font-display);
font-weight: 800;
font-size: clamp(1.35rem, 2.6vw, 1.7rem);
letter-spacing: -0.025em;
line-height: 1.1;
color: hsl(var(--color-text));
}
.panel-promise {
margin: 0.5rem 0 0;
font-size: 0.98rem;
line-height: 1.5;
color: hsl(var(--color-text) / 0.78);
}
.panel-tools {
list-style: none;
margin: 0.9rem 0 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.panel-tool {
display: inline-flex;
align-items: baseline;
gap: 0.4rem;
padding: 0.4rem 0.8rem;
border-radius: 20px;
background: hsl(var(--color-bg) / 0.7);
font-size: 0.85rem;
color: hsl(var(--color-text));
transition: transform 0.1s ease, background 0.2s ease;
}
.panel-tool:hover {
transform: translateY(-1px);
background: hsl(var(--color-bg));
}
.panel-tool:active {
transform: translateY(0);
}
.panel-tool-name {
font-weight: 700;
}
.panel-tool-app {
font-family: var(--font-mono);
font-size: 0.7rem;
color: hsl(var(--hue));
}
.panel-tool-state {
font-family: var(--font-mono);
font-size: 0.65rem;
color: hsl(var(--color-text) / 0.45);
}
.panel-stacks {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.25rem 0.6rem;
margin: 0.9rem 0 0;
font-size: 0.8rem;
color: hsl(var(--color-text) / 0.7);
}
.panel-stacks-label {
font-family: var(--font-mono);
font-size: 0.68rem;
color: hsl(var(--color-text) / 0.45);
}
.panel-stacks-more {
font-family: var(--font-mono);
font-size: 0.72rem;
color: hsl(var(--hue));
}
.panel-stack::before {
content: '';
display: inline-block;
width: 0.4rem;
height: 0.4rem;
margin-right: 0.3rem;
border-radius: 50%;
background: hsl(var(--hue));
vertical-align: 0.05em;
}
.panel-more {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin-top: 1rem;
font-family: var(--font-display);
font-weight: 700;
font-size: 0.92rem;
color: hsl(var(--hue));
}
.panel-more:hover span {
transform: translateX(2px);
}
.panel-more span {
transition: transform 0.12s ease;
}
.panel-enter-active,
.panel-leave-active {
transition: opacity 0.18s ease, transform 0.18s ease;
}
.panel-enter-from {
opacity: 0;
transform: translateY(6px);
}
.panel-leave-to {
opacity: 0;
transform: translateY(-4px);
}
@media (max-width: 480px) {
.console { --track-h: 8.5rem; }
.console-board { padding: 1rem 0.6rem 0.75rem; gap: 0.35rem; }
.console-scale { font-size: 0.62rem; }
.fader-cap { width: 2.2rem; margin-left: -1.1rem; }
.fader-name { font-size: 0.88rem; }
.fader-prefix { font-size: 0.52rem; letter-spacing: 0.08em; }
.fader-lever { font-size: 0.68rem; padding: 0.2rem 0.4rem; }
}
@media (prefers-reduced-motion: reduce) {
.fader,
.fader--held.fader--rising,
.fader-fill,
.fader-name,
.fader-lever,
.panel-enter-active,
.panel-leave-active {
transition: none;
}
}
</style>
+372
View File
@@ -0,0 +1,372 @@
<template>
<div class="format">
<header v-if="format && showHeader" class="format-head">
<!-- The accordion player at the end of the title, on its baseline: on the event card it stood
at the height of the reader, and its ground line touched the card's link (rules c, h).
S and from 1200 px: the band above leaves 125 px, and a perched bird keeps a void of its
own height above it (rule f); 8 px under the baseline, within the 12 px of rule e -->
<h2 class="format-title">{{ format.title }}<UiOiseauPose nom="accordeoniste" pose="perche" bout left="16px" bottom="-8px" :w="72" retourne ton="accent" :seuil="1200" /></h2>
<p class="format-text">{{ format.text }}</p>
</header>
<div class="format-grid">
<!-- Éditorial : le livre et les chansons -->
<article v-if="book" class="format-card format-card--book">
<!-- No bird behind the book card (DA, 03/11 after 19h): lowered under the header's first
line, the reader only showed her bubble; the page margin and the gutter leave her no
side to come out by, and the free top of the card holds the accordion player. She
stands on the order page -->
<div
class="format-cover"
role="img"
:aria-label="book.coverAlt"
:style="{ backgroundImage: `url(${book.coverImage})` }"
/>
<div class="format-body">
<p class="format-kicker">{{ book.kicker }}</p>
<h3 class="format-card-title">{{ book.title }}</h3>
<p class="format-desc">{{ book.description }}</p>
<div class="format-actions">
<button type="button" class="fbtn fbtn--main" @click="$emit('open-player')">
<span class="i-lucide-play h-4 w-4" aria-hidden="true" />
{{ book.cta?.player }}
</button>
<button type="button" class="fbtn" @click="$emit('open-pdf')">
<span class="i-lucide-book-open h-4 w-4" aria-hidden="true" />
{{ book.cta?.pdf }}
</button>
<NuxtLink to="/economique/modele-eco" class="fbtn fbtn--quiet">
<span class="i-lucide-list h-4 w-4" aria-hidden="true" />
Présentation des chapitres
</NuxtLink>
<NuxtLink to="/economique/commande" class="fbtn fbtn--quiet">
<span class="i-lucide-shopping-bag h-4 w-4" aria-hidden="true" />
Commandez le livre
</NuxtLink>
</div>
</div>
</article>
<!-- Événementiel -->
<NuxtLink v-if="evenement" :to="evenement.to" class="format-card format-card--event">
<p class="format-kicker">
{{ evenement.teaser }}
<span v-if="evenement.gestation" class="format-state">en gestation</span>
</p>
<h3 class="format-card-title format-card-title--event">{{ evenement.subtitle }}</h3>
<p v-if="evenement.tagline" class="format-tagline">{{ evenement.tagline }}</p>
<ul v-if="evenement.config.length" class="format-config">
<li v-for="c in evenement.config" :key="c.label">
<span :class="`i-lucide-${c.icon}`" class="h-4 w-4" aria-hidden="true" />
<span>{{ c.label }}</span>
</li>
</ul>
<span class="format-more">
Voir l'événement
<span class="i-lucide-arrow-right h-4 w-4" aria-hidden="true" />
</span>
</NuxtLink>
</div>
<!-- Reproduction + à qui l'on s'adresse -->
<div v-if="evenement?.reproduction || hero?.addressees?.length" class="format-repro">
<p v-if="evenement?.reproduction" class="format-repro-line">
<span class="format-basins" aria-hidden="true"><i v-for="n in 5" :key="n" /></span>
{{ evenement.reproduction }}
</p>
<div v-if="hero?.addressees?.length" class="format-audience">
<p class="format-audience-label">{{ hero.audience }}</p>
<ul>
<li v-for="a in hero.addressees" :key="a.label">
<strong>{{ a.label }}</strong> {{ a.value }}
</li>
</ul>
</div>
</div>
</div>
</template>
<script setup lang="ts">
withDefaults(defineProps<{
format?: { title: string, text: string }
book?: any
evenement?: any
hero?: any
showHeader?: boolean
}>(), {
showHeader: true,
})
defineEmits<{
'open-player': []
'open-pdf': []
}>()
</script>
<style scoped>
.format {
display: grid;
gap: 1.5rem;
}
.format-head {
max-width: 44rem;
}
.format-title {
margin: 0;
font-family: var(--font-display);
font-weight: 800;
font-size: clamp(1.75rem, 4.2vw, 2.75rem);
letter-spacing: -0.03em;
line-height: 1.05;
color: hsl(var(--color-text));
}
.format-text {
margin: 0.75rem 0 0;
font-size: 1.1rem;
line-height: 1.5;
color: hsl(var(--color-text) / 0.72);
}
.format-grid {
isolation: isolate;
display: grid;
gap: 1.25rem;
}
@media (min-width: 1024px) {
.format-grid { grid-template-columns: 1.35fr 1fr; }
}
.format-card {
position: relative;
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1.5rem;
border-radius: 16px;
background: hsl(var(--color-surface));
}
.format-card--book {
flex-direction: row;
gap: 1.5rem;
}
.format-cover {
flex-shrink: 0;
width: clamp(6.5rem, 16vw, 10rem);
aspect-ratio: 1756 / 2480;
border-radius: 6px;
background-size: cover;
background-position: center;
box-shadow: 0 16px 30px -18px hsl(var(--color-text) / 0.6);
}
.format-body {
min-width: 0;
}
.format-kicker {
margin: 0;
font-family: var(--font-mono);
font-size: 0.72rem;
color: hsl(var(--axis-eco));
}
.format-card--event .format-kicker {
color: hsl(var(--axis-cit));
}
.format-state {
margin-left: 0.35rem;
color: hsl(var(--color-text) / 0.5);
}
.format-state::before {
content: '◌ ';
}
.format-card-title {
margin: 0.3rem 0 0;
font-family: var(--font-display);
font-weight: 800;
font-size: clamp(1.3rem, 2.4vw, 1.6rem);
letter-spacing: -0.02em;
line-height: 1.12;
color: hsl(var(--color-text));
}
.format-card-title--event {
font-weight: 700;
}
.format-desc {
margin: 0.5rem 0 0;
font-size: 0.95rem;
line-height: 1.5;
color: hsl(var(--color-text) / 0.72);
}
.format-actions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 1rem;
}
.fbtn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-height: 2.5rem;
padding: 0.45rem 1rem;
border-radius: 20px;
font-size: 0.86rem;
font-weight: 700;
color: hsl(var(--color-text) / 0.85);
background: hsl(var(--color-text) / 0.07);
transition: transform 0.1s ease, background 0.2s ease;
}
.fbtn:hover { transform: translateY(-1px); background: hsl(var(--axis-eco) / 0.14); }
.fbtn:active { transform: translateY(0); }
.fbtn--main {
color: hsl(var(--color-bg));
background: hsl(var(--axis-eco));
}
.fbtn--main:hover { background: hsl(var(--axis-eco) / 0.88); }
.fbtn--quiet {
font-weight: 400;
background: transparent;
}
.format-card--event {
color: hsl(var(--color-text));
transition: transform 0.12s ease;
}
.format-card--event:hover {
transform: translateY(-3px);
}
.format-tagline {
margin: 0.2rem 0 0;
font-family: var(--font-display);
font-size: 1.05rem;
color: hsl(var(--color-text) / 0.7);
}
.format-config {
list-style: none;
margin: 0.75rem 0 0;
padding: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
font-size: 0.85rem;
color: hsl(var(--color-text) / 0.78);
}
.format-config li {
display: flex;
align-items: center;
gap: 0.45rem;
}
.format-config li > span:first-child {
color: hsl(var(--axis-cit));
flex-shrink: 0;
}
.format-more {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin-top: auto;
padding-top: 1rem;
font-family: var(--font-display);
font-weight: 700;
color: hsl(var(--axis-cit));
}
/* Reproduction */
.format-repro {
display: grid;
gap: 1.25rem;
padding: 1.25rem 0 0;
}
@media (min-width: 768px) {
.format-repro {
grid-template-columns: 1fr 1fr;
align-items: start;
}
}
.format-repro-line {
margin: 0;
font-family: var(--font-display);
font-weight: 700;
font-size: clamp(1.15rem, 2.2vw, 1.4rem);
line-height: 1.25;
letter-spacing: -0.015em;
color: hsl(var(--color-text));
}
/* One basin, then its copies */
.format-basins {
display: flex;
gap: 0.35rem;
margin-bottom: 0.6rem;
}
.format-basins i {
display: block;
width: 0.8rem;
height: 0.8rem;
border-radius: 50%;
box-shadow: inset 0 0 0 1.5px hsl(var(--axis-cit) / 0.6);
}
.format-basins i:first-child {
background: hsl(var(--axis-cit));
box-shadow: none;
}
.format-audience-label {
margin: 0 0 0.5rem;
font-family: var(--font-mono);
font-size: 0.72rem;
color: hsl(var(--color-text) / 0.5);
}
.format-audience ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.95rem;
color: hsl(var(--color-text) / 0.7);
}
.format-audience strong {
color: hsl(var(--color-text));
}
@media (max-width: 640px) {
.format-card--book { flex-direction: column; }
.format-config { grid-template-columns: 1fr; }
}
@media (prefers-reduced-motion: reduce) {
.fbtn, .format-card--event { transition: none; }
}
</style>
+237
View File
@@ -0,0 +1,237 @@
<template>
<div class="va">
<!-- Hero : the headline, and the three cursors to push -->
<section class="va-hero">
<div class="container-content va-hero-grid">
<div class="va-intro">
<h1 class="va-h1">
<span class="va-h1-a">{{ heading[0] }}</span>
<span class="va-h1-b">{{ heading[1] }}</span>
</h1>
<p class="va-axes">
<template v-for="(seg, i) in axesLine" :key="i">
<span v-if="seg.hue" class="va-axes-word" :style="{ '--hue': `var(${seg.hue})` }">{{ seg.text }}</span>
<template v-else>{{ seg.text }}</template>
</template>
</p>
<p class="va-means">{{ heading[4] }}</p>
<p v-if="hero.approach" class="va-approach">{{ hero.approach }}</p>
</div>
<div class="va-console">
<p class="va-console-title">
{{ heading[2] }}
</p>
<!-- The DJ stands behind the console board, like behind its decks: only its head, with
its headphones, shows over the board. Perched on the board, under the sticky header,
it had no void of its own height above it (rule f, DA 03/11 after 17h35). The scene
keeps it under the board without touching the console -->
<div class="va-console-scene">
<UiOiseauPose nom="dj" pose="derriere" right="24px" top="-95px" :w="150" retourne ton="accent" />
<UiOiseauPose nom="dj" pose="derriere" right="24px" top="-95px" :w="150" retourne ton="accent" mobile-seul :seuil="768" />
<HomeConsole
v-model="active"
:axes="axes"
:prefix="consoleLabels?.prefix"
:top-label="consoleLabels?.top"
:bottom-label="consoleLabels?.bottom"
:stacks="bouquet?.inService"
:stacks-to="lpk?.to"
:stacks-label="lpk ? `En service chez ${lpk.name} :` : 'En service :'"
/>
</div>
</div>
</div>
</section>
<!-- Pourquoi -->
<section class="va-band">
<div class="container-content">
<HomeCitations :lines="hero.citations ?? []" />
</div>
</section>
<!-- Le format -->
<section class="va-section">
<div class="container-content">
<HomeFormat
:format="format"
:book="book"
:evenement="evenement"
:hero="hero"
@open-player="$emit('open-player')"
@open-pdf="$emit('open-pdf')"
/>
</div>
</section>
<!-- La boîte à outils -->
<section class="va-section va-section--tools">
<div class="container-content">
<h2 class="va-h2">{{ toolboxTitle }}</h2>
<HomeToolbox
:oiseaux="toolboxOiseaux"
:axes="axes"
:lpk="lpk"
:bouquet="bouquet"
:gratewizard="gratewizard"
anchors
@open-player="$emit('open-player')"
@open-pdf="$emit('open-pdf')"
/>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import type { AxisKey } from '~/composables/useHomeData'
import type { OiseauPlace } from '~/utils/oiseauPose'
defineEmits<{
'open-player': []
'open-pdf': []
}>()
const { hero, axes, lpk, bouquet, evenement, book, format, gratewizard, consoleLabels, toolboxTitle } = await useHomeData()
const heading = computed<string[]>(() => hero.value.heading ?? [])
// « Autonomie numérique, économique, citoyenne. » — each axis word in its own hue
const AXIS_WORDS: Record<string, string> = {
numérique: '--axis-num',
économique: '--axis-eco',
citoyenne: '--axis-cit',
}
const axesLine = computed(() =>
(heading.value[3] ?? '')
.split(/(numérique|économique|citoyenne)/)
.filter(Boolean)
.map(text => ({ text, hue: AXIS_WORDS[text] })))
const active = ref<AxisKey>('numerique')
// The governance crew stands in the empty space under the citoyenne column of the toolbox
const toolboxOiseaux: Partial<Record<AxisKey, OiseauPlace[]>> = {
politique: [
{ nom: 'capitaine', pose: 'libre', left: 'calc(34% - 75px)', bottom: '-190px', w: 150, op: 0.24 },
{ nom: 'matelot', pose: 'libre', left: 'calc(80% - 40px)', bottom: '-300px', w: 80, op: 0.26, retourne: true },
],
}
</script>
<style scoped>
.va-hero {
position: relative;
overflow: hidden;
padding: clamp(2.5rem, 6vw, 5rem) 1rem clamp(2.5rem, 5vw, 4rem);
}
.va-hero-grid {
position: relative;
z-index: 1;
display: grid;
gap: 2.5rem;
align-items: center;
}
@media (min-width: 1024px) {
.va-hero-grid {
grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr);
gap: 4rem;
}
}
.va-h1 {
margin: 0;
display: flex;
flex-direction: column;
font-family: var(--font-display);
letter-spacing: -0.035em;
line-height: 0.98;
}
.va-h1-a {
font-weight: 800;
font-size: clamp(2.4rem, 6.4vw, 4.6rem);
color: hsl(var(--color-text));
}
.va-h1-b {
margin-top: 0.3rem;
font-weight: 400;
font-size: clamp(1.6rem, 3.8vw, 2.7rem);
letter-spacing: -0.025em;
color: hsl(var(--color-text) / 0.55);
}
.va-axes {
margin: 1.75rem 0 0;
font-family: var(--font-display);
font-weight: 800;
font-size: clamp(1.3rem, 2.6vw, 1.85rem);
letter-spacing: -0.025em;
line-height: 1.15;
color: hsl(var(--color-text));
}
.va-axes-word {
color: hsl(var(--hue));
}
.va-means {
margin: 0.35rem 0 0;
font-family: var(--font-display);
font-style: italic;
font-size: 1.05rem;
color: hsl(var(--color-text) / 0.6);
}
.va-approach {
margin: 1.75rem 0 0;
max-width: 26rem;
font-size: 1rem;
line-height: 1.5;
color: hsl(var(--color-text) / 0.8);
}
.va-approach::after {
content: ' ↓';
color: hsl(var(--color-primary));
}
@media (min-width: 1024px) {
.va-approach::after { content: ' →'; }
}
.va-console-scene {
position: relative;
isolation: isolate;
}
.va-console-title {
position: relative;
margin: 0 0 0.75rem;
font-family: var(--font-display);
font-weight: 700;
font-size: 1.1rem;
color: hsl(var(--color-accent));
}
.va-band {
padding: clamp(2.5rem, 6vw, 4.5rem) 1rem;
background: hsl(var(--color-surface) / 0.55);
}
.va-section {
padding: clamp(3rem, 7vw, 5.5rem) 1rem 0;
}
.va-h2 {
margin: 0 0 1.5rem;
font-family: var(--font-display);
font-weight: 800;
font-size: clamp(1.75rem, 4.2vw, 2.75rem);
letter-spacing: -0.03em;
line-height: 1.05;
color: hsl(var(--color-text));
}
</style>
+152 -40
View File
@@ -1,62 +1,80 @@
<template> <template>
<div class="mt-16"> <section class="messages-section">
<div class="container-content messages-grid">
<!-- Formulaire --> <!-- Formulaire -->
<UiScrollReveal :delay="500"> <UiScrollReveal>
<div class="message-form-card"> <div class="message-form-card">
<h3 class="font-display text-lg font-bold text-white mb-4">Laisser un message</h3> <!-- The postwoman stands on the message form -->
<UiOiseauPose nom="factrice" pose="perche" right="30px" :w="76" retourne :seuil="1200" />
<h3 class="font-display text-lg font-bold form-title mb-4">Laisser un message</h3>
<form v-if="!submitted" class="space-y-3" @submit.prevent="send"> <form v-if="!submitted" class="space-y-3" @submit.prevent="send">
<div class="grid gap-3 sm:grid-cols-2">
<input <input
v-model="form.author" v-model="form.author"
type="text" type="text"
placeholder="Votre nom *" placeholder="Votre nom"
required
class="msg-input" class="msg-input"
/> />
<input <p class="hint-text text-xs -mt-1 px-1">Pour recevoir une réponse, laissez votre e-mail dans le message.</p>
v-model="form.email" <select v-model="form.type" class="msg-input msg-input--select">
type="email" <option value="question">Question</option>
placeholder="Email (optionnel)" <option value="suggestion">Suggestion</option>
class="msg-input" <option value="retour">Retour d'expérience</option>
/> </select>
</div>
<textarea <textarea
v-model="form.text" v-model="form.text"
placeholder="Votre message *" placeholder="Votre message"
required
rows="3" rows="3"
class="msg-input resize-none" class="msg-input resize-none"
/> />
<div class="flex justify-end"> <div class="flex justify-end">
<button type="submit" class="btn-primary text-sm" :disabled="sending"> <button type="submit" class="btn-primary text-sm" :disabled="sending || !canSend">
<div v-if="sending" class="i-lucide-loader-2 h-4 w-4 animate-spin mr-2" /> <div v-if="sending" class="i-lucide-loader-2 h-4 w-4 animate-spin mr-2" />
Envoyer Envoyer
</button> </button>
</div> </div>
</form> </form>
<div v-else class="text-center py-4"> <div v-else class="text-center py-6">
<div class="i-lucide-check-circle h-8 w-8 text-green-400 mx-auto mb-2" /> <div class="i-lucide-heart-handshake h-10 w-10 text-primary mx-auto mb-3" />
<p class="text-white/80">Merci pour votre message !</p> <p class="msg-confirm-title font-display text-lg font-semibold">Merci pour votre message !</p>
<p class="text-white/40 text-sm mt-1">Il sera visible après modération.</p> <p class="msg-confirm-sub text-sm mt-2 max-w-md mx-auto leading-relaxed">
Il sera lu et traité dans un délai... humainement raisonnable.
</p>
<p class="msg-confirm-note text-xs mt-4 italic">
Chaque message est un premier pas dans l'aventure.
</p>
</div> </div>
</div> </div>
</UiScrollReveal> </UiScrollReveal>
<!-- 2 derniers messages publiés --> <!-- 2 derniers messages publiés -->
<UiScrollReveal v-if="messages?.length" :delay="600"> <UiScrollReveal v-if="messages?.length" :delay="100">
<div class="mt-8 space-y-4"> <div class="space-y-4">
<h3 class="font-display text-lg font-bold text-white/80 text-center">Derniers messages</h3> <h3 class="font-display text-lg font-bold section-title">Derniers messages</h3>
<div v-for="msg in messages.slice(0, 2)" :key="msg.id" class="message-card"> <div v-for="msg in messages.slice(0, 2)" :key="msg.id" class="message-card">
<p class="text-white/80 text-sm leading-relaxed">{{ msg.text }}</p> <!-- En-tête auteur -->
<div class="mt-2 flex items-center gap-2 text-xs text-white/40"> <div class="flex items-center gap-2 mb-2">
<span class="font-semibold text-white/60">{{ msg.author }}</span> <span class="msg-author font-semibold text-sm">{{ msg.author }}</span>
<span>&middot;</span> <span class="type-pill">{{ typeLabel(msg.type) }}</span>
<span>{{ formatDate(msg.createdAt) }}</span> <span class="msg-date text-xs ml-auto">{{ formatDate(msg.createdAt) }}</span>
</div>
<!-- Texte du message -->
<p class="msg-text text-sm leading-relaxed">{{ msg.text }}</p>
<!-- Réponse -->
<div v-if="msg.reply?.text" class="reply-thread">
<div class="reply-connector" aria-hidden="true" />
<div class="reply-block">
<div class="flex items-center gap-1.5 mb-1">
<div class="i-lucide-corner-down-right h-3 w-3 reply-icon" />
<span class="reply-author text-xs font-semibold">Le Librodrome</span>
</div>
<p class="reply-text text-sm leading-relaxed italic">{{ msg.reply.text }}</p>
</div> </div>
</div> </div>
<div class="text-center"> </div>
<div>
<NuxtLink to="/messages" class="btn-ghost text-sm"> <NuxtLink to="/messages" class="btn-ghost text-sm">
Voir tous les messages Voir tous les messages
<div class="i-lucide-arrow-right ml-1 h-3.5 w-3.5" /> <div class="i-lucide-arrow-right ml-1 h-3.5 w-3.5" />
@@ -65,16 +83,31 @@
</div> </div>
</UiScrollReveal> </UiScrollReveal>
</div> </div>
</section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const { data: messages } = await useFetch('/api/messages') const { data: messages } = await useFetch('/api/messages')
const form = reactive({ author: '', email: '', text: '' }) const form = reactive({ author: '', text: '', type: 'question' })
const sending = ref(false) const sending = ref(false)
const submitted = ref(false) const submitted = ref(false)
const canSend = computed(() => form.text.trim().length > 0)
const TYPE_LABELS: Record<string, string> = {
reaction: 'Réaction',
question: 'Question',
suggestion: 'Suggestion',
retour: 'Retour',
}
function typeLabel(type: string) {
return TYPE_LABELS[type] ?? type
}
async function send() { async function send() {
if (!canSend.value) return
sending.value = true sending.value = true
try { try {
await $fetch('/api/messages', { method: 'POST', body: form }) await $fetch('/api/messages', { method: 'POST', body: form })
@@ -105,22 +138,59 @@ function formatDate(iso: string) {
</script> </script>
<style scoped> <style scoped>
/* ── Couleurs adaptatives (dark + light) ── */
.form-title { color: hsl(var(--color-text)); }
.hint-text { color: hsl(var(--color-text) / 0.35); }
.section-title { color: hsl(var(--color-text) / 0.85); }
.msg-author { color: hsl(var(--color-text) / 0.75); }
.msg-date { color: hsl(var(--color-text) / 0.38); }
.msg-text { color: hsl(var(--color-text) / 0.78); white-space: pre-line; }
.msg-confirm-title { color: hsl(var(--color-text) / 0.95); }
.msg-confirm-sub { color: hsl(var(--color-text) / 0.6); }
.msg-confirm-note { color: hsl(var(--color-primary) / 0.65); }
.reply-icon { color: hsl(var(--color-primary) / 0.5); }
.reply-author { color: hsl(var(--color-primary) / 0.8); }
.reply-text { color: hsl(var(--color-text) / 0.62); }
/* ── Mise en page ── */
.messages-section {
padding: clamp(3rem, 7vw, 5.5rem) 1rem 2rem;
}
.messages-grid {
display: grid;
gap: 2rem;
align-items: start;
}
@media (min-width: 1024px) {
.messages-grid { grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); }
}
/* ── Carte formulaire ── */
.message-form-card { .message-form-card {
position: relative;
background: hsl(var(--color-surface)); background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-text) / 0.1); border-radius: 16px;
border-radius: 0.75rem;
padding: 1.5rem; padding: 1.5rem;
} }
.msg-input { .msg-input {
width: 100%; width: 100%;
padding: 0.5rem 0.75rem; min-height: 2.5rem;
border-radius: 0.5rem; padding: 0.55rem 0.85rem;
border: 1px solid hsl(var(--color-text) / 0.12); border-radius: 12px;
border: none;
background: hsl(var(--color-bg)); background: hsl(var(--color-bg));
color: hsl(var(--color-text)); color: hsl(var(--color-text));
font-size: 0.875rem; font-size: 0.95rem;
transition: border-color 0.2s; font-family: inherit;
transition: box-shadow 0.2s;
}
.msg-input--select {
width: auto;
cursor: pointer;
} }
.msg-input::placeholder { .msg-input::placeholder {
@@ -129,13 +199,55 @@ function formatDate(iso: string) {
.msg-input:focus { .msg-input:focus {
outline: none; outline: none;
border-color: hsl(var(--color-primary) / 0.5); box-shadow: 0 0 0 2px hsl(var(--color-primary) / 0.5);
} }
/* ── Carte message ── */
.message-card { .message-card {
background: hsl(var(--color-surface)); background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-text) / 0.1); border-radius: 16px;
border-radius: 0.75rem;
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
} }
.type-pill {
font-size: 0.6rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.1rem 0.45rem;
border-radius: 9999px;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary) / 0.7);
}
/* ── Réponse avec liaison graphique ── */
.reply-thread {
position: relative;
display: flex;
gap: 0;
margin-top: 0.75rem;
padding-left: 1.25rem;
}
.reply-connector {
position: absolute;
left: 0.5rem;
top: -0.5rem;
bottom: 0.5rem;
width: 2px;
background: linear-gradient(
to bottom,
hsl(var(--color-primary) / 0.35),
hsl(var(--color-primary) / 0.15)
);
border-radius: 2px;
}
.reply-block {
flex: 1;
background: hsl(var(--color-primary) / 0.05);
border-left: 2px solid hsl(var(--color-primary) / 0.25);
border-radius: 0 0.5rem 0.5rem 0;
padding: 0.6rem 0.875rem;
}
</style> </style>
+198
View File
@@ -0,0 +1,198 @@
<template>
<div class="tool" :class="{ 'tool--gestation': tool.gestation }">
<div class="tool-main">
<span class="tool-icon" aria-hidden="true">
<span v-if="tool.icon === 'g1'" class="tool-g1">Ğ1</span>
<span v-else :class="`i-lucide-${tool.icon}`" class="h-4 w-4" />
</span>
<div class="tool-text">
<component :is="tool.to ? NuxtLink : 'span'" :to="tool.to" class="tool-name">
{{ tool.label }}
</component>
<UiStatusBadge class="tool-status" :status="tool.status" :gestation="tool.gestation" :since="tool.since" />
<p class="tool-desc">{{ tool.description }}</p>
<p v-if="tool.presentation" class="tool-app">
<strong>{{ tool.presentation.title }}</strong> — {{ tool.presentation.text }}
</p>
</div>
</div>
<div v-if="tool.actions?.length" class="tool-actions">
<template v-for="action in tool.actions" :key="action.label">
<a
v-if="action.href"
:href="action.href"
target="_blank"
rel="noopener noreferrer"
class="tool-btn"
:class="{ 'tool-btn--main': action.highlight }"
>
<span :class="`i-lucide-${action.icon}`" class="h-3.5 w-3.5" aria-hidden="true" />
{{ action.label }}
</a>
<NuxtLink
v-else-if="action.to"
:to="action.to"
class="tool-btn"
:class="{ 'tool-btn--quiet': action.secondary }"
>
<span :class="`i-lucide-${action.icon}`" class="h-3.5 w-3.5" aria-hidden="true" />
{{ action.label }}
</NuxtLink>
<button
v-else
type="button"
class="tool-btn"
:class="{ 'tool-btn--main': action.highlight }"
@click="$emit('action', action.id)"
>
<span :class="`i-lucide-${action.icon}`" class="h-3.5 w-3.5" aria-hidden="true" />
{{ action.label }}
</button>
</template>
<span v-for="action in tool.actions.filter(a => a.note)" :key="`note-${action.label}`" class="tool-note">
{{ action.note }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import type { HomeTool } from '~/composables/useHomeData'
defineProps<{
tool: HomeTool
}>()
defineEmits<{
action: [id: string]
}>()
const NuxtLink = resolveComponent('NuxtLink')
</script>
<style scoped>
.tool {
padding: 0.85rem 0.9rem;
border-radius: 12px;
background: hsl(var(--color-bg) / 0.55);
}
.tool-main {
display: flex;
gap: 0.7rem;
}
.tool-icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2rem;
height: 2rem;
border-radius: 10px;
background: hsl(var(--hue) / 0.12);
color: hsl(var(--hue));
}
.tool-g1 {
font-family: var(--font-display);
font-weight: 800;
font-size: 0.8rem;
}
.tool-text {
min-width: 0;
}
.tool-name {
font-family: var(--font-display);
font-weight: 700;
font-size: 1rem;
color: hsl(var(--color-text));
}
a.tool-name:hover {
color: hsl(var(--hue));
}
.tool-status {
margin-left: 0.4rem;
}
.tool-note {
align-self: center;
font-family: var(--font-mono);
font-size: 0.66rem;
color: hsl(var(--axis-cit));
}
.tool-desc {
margin: 0.2rem 0 0;
font-size: 0.85rem;
line-height: 1.45;
color: hsl(var(--color-text) / 0.68);
}
.tool-app {
margin: 0.35rem 0 0;
font-size: 0.78rem;
line-height: 1.4;
color: hsl(var(--color-text) / 0.6);
}
.tool-app strong {
font-family: var(--font-mono);
font-weight: 500;
color: hsl(var(--hue));
}
.tool-actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.7rem;
padding-left: 2.7rem;
}
.tool-btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
min-height: 2.25rem;
padding: 0.3rem 0.8rem;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 700;
color: hsl(var(--color-text) / 0.8);
background: hsl(var(--color-text) / 0.06);
transition: transform 0.1s ease, background 0.2s ease;
}
.tool-btn:hover {
transform: translateY(-1px);
background: hsl(var(--hue) / 0.14);
}
.tool-btn:active {
transform: translateY(0);
}
.tool-btn--main {
color: hsl(var(--color-bg));
background: hsl(var(--hue));
}
.tool-btn--main:hover {
background: hsl(var(--hue) / 0.88);
}
.tool-btn--quiet {
font-weight: 400;
background: transparent;
}
@media (prefers-reduced-motion: reduce) {
.tool-btn { transition: none; }
}
</style>
+251
View File
@@ -0,0 +1,251 @@
<template>
<div class="toolbox-wrap">
<div class="toolbox" :class="{ 'toolbox--single': axes.length === 1 }">
<section
v-for="axis in axes"
:id="anchors ? axis.to.slice(1) : undefined"
:key="axis.key"
class="toolbox-axis"
:style="{ '--hue': `var(${axis.hue})` }"
:aria-labelledby="`tb-${uid}-${axis.key}`"
>
<UiOiseauPose v-for="o in oiseaux?.[axis.key] ?? []" :key="`${o.nom}-${o.pose}`" v-bind="o" class="toolbox-oiseau" />
<header class="toolbox-head">
<p class="toolbox-lever">{{ axis.lever }}</p>
<h3 :id="`tb-${uid}-${axis.key}`" class="toolbox-title">
<NuxtLink :to="axis.to">{{ axis.title }}</NuxtLink>
</h3>
<p v-if="showPromise && axis.promise" class="toolbox-promise">{{ axis.promise }}</p>
</header>
<ul class="toolbox-tools">
<li v-for="tool in axis.items" :key="tool.label">
<HomeToolItem :tool="tool" @action="onAction" />
</li>
</ul>
<!-- Numérique : the stacks LaPlanK serves -->
<NuxtLink
v-if="axis.key === 'numerique' && lpk && bouquet"
:to="lpk.to"
class="toolbox-lpk"
>
<span class="toolbox-lpk-head">
<strong>{{ lpk.name }}</strong> <span>{{ lpk.text }}</span>
</span>
<span class="toolbox-lpk-stacks">
<span v-for="s in bouquet.inService" :key="s.name" class="toolbox-lpk-stack">{{ s.name }}</span>
</span>
<span class="toolbox-lpk-more">
{{ bouquet.title }} · {{ bouquet.stacks.length }}
<span class="i-lucide-arrow-right h-3.5 w-3.5" aria-hidden="true" />
</span>
</NuxtLink>
<!-- Économique : grateWizard -->
<button
v-if="axis.key === 'economie' && gratewizard"
type="button"
class="toolbox-gw"
@click="launch()"
>
<span class="i-lucide-sparkles h-4 w-4" aria-hidden="true" />
<span class="toolbox-gw-text">
<strong>{{ gratewizard.title }}</strong>
<span>{{ gratewizard.subtitle }}</span>
</span>
<span class="i-lucide-arrow-up-right h-4 w-4" aria-hidden="true" />
</button>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import type { AxisKey, HomeAxis } from '~/composables/useHomeData'
import type { OiseauPlace } from '~/utils/oiseauPose'
withDefaults(defineProps<{
axes: HomeAxis[]
lpk?: { name: string, text: string, to: string }
bouquet?: { title: string, inService: { name?: string }[], stacks: unknown[] } | null
gratewizard?: { title: string, subtitle: string }
showPromise?: boolean
anchors?: boolean
/** Birds standing by an axis column (home page) */
oiseaux?: Partial<Record<AxisKey, OiseauPlace[]>>
}>(), {
showPromise: true,
anchors: false,
})
const emit = defineEmits<{
'open-player': []
'open-pdf': []
}>()
const uid = useId()
const { launch } = useGrateWizard()
function onAction(id: string) {
if (id === 'open-player') emit('open-player')
else if (id === 'open-pdf') emit('open-pdf')
}
</script>
<style scoped>
.toolbox-wrap {
container-type: inline-size;
}
.toolbox {
display: grid;
gap: 1.25rem;
}
@container (min-width: 60rem) {
.toolbox:not(.toolbox--single) {
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: start;
}
}
/* The birds under a column only make sense with the three columns side by side */
@container (max-width: 59.99rem) {
.toolbox-oiseau { display: none; }
}
.toolbox-axis {
position: relative;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1.25rem;
border-radius: 16px;
background: hsl(var(--color-surface));
box-shadow: inset 0 3px 0 hsl(var(--hue));
scroll-margin-top: calc(var(--header-height) + 1rem);
}
.toolbox-lever {
margin: 0;
font-family: var(--font-mono);
font-size: 0.7rem;
color: hsl(var(--hue));
}
.toolbox-title {
margin: 0.15rem 0 0;
font-family: var(--font-display);
font-weight: 800;
font-size: 1.3rem;
letter-spacing: -0.02em;
color: hsl(var(--color-text));
}
.toolbox-title a:hover {
color: hsl(var(--hue));
}
.toolbox-promise {
margin: 0.35rem 0 0;
font-size: 0.9rem;
line-height: 1.45;
color: hsl(var(--color-text) / 0.7);
}
.toolbox-tools {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* LaPlanK */
.toolbox-lpk {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.9rem;
border-radius: 12px;
background: hsl(var(--hue) / 0.1);
color: hsl(var(--color-text));
transition: transform 0.12s ease;
}
.toolbox-lpk:hover {
transform: translateY(-3px);
}
.toolbox-lpk-head {
font-size: 0.82rem;
line-height: 1.4;
color: hsl(var(--color-text) / 0.7);
}
.toolbox-lpk-head strong {
font-family: var(--font-display);
font-size: 1rem;
color: hsl(var(--hue));
}
.toolbox-lpk-stacks {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.toolbox-lpk-stack {
padding: 0.15rem 0.55rem;
border-radius: 20px;
font-size: 0.75rem;
background: hsl(var(--color-bg) / 0.75);
}
.toolbox-lpk-more {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-family: var(--font-mono);
font-size: 0.7rem;
color: hsl(var(--hue));
}
/* grateWizard */
.toolbox-gw {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.85rem 0.9rem;
border-radius: 12px;
text-align: left;
color: hsl(var(--hue));
background: hsl(var(--hue) / 0.1);
transition: transform 0.12s ease;
}
.toolbox-gw:hover {
transform: translateY(-3px);
}
.toolbox-gw-text {
flex: 1;
display: flex;
flex-direction: column;
font-size: 0.8rem;
color: hsl(var(--color-text) / 0.7);
}
.toolbox-gw-text strong {
font-family: var(--font-display);
font-size: 1rem;
color: hsl(var(--color-text));
}
@media (prefers-reduced-motion: reduce) {
.toolbox-lpk, .toolbox-gw { transition: none; }
}
</style>
+16 -44
View File
@@ -1,44 +1,7 @@
<template> <template>
<footer class="footer-wrap border-t border-[hsl(var(--color-text)/0.1)] bg-[hsl(var(--color-surface))]"> <footer class="footer-wrap border-t border-[hsl(var(--color-text)/0.1)] bg-[hsl(var(--color-surface))]">
<!-- Shadok pattern --> <!-- The frieze of the birds (studio DA), standing on the bottom edge -->
<svg class="footer-shadok-pattern" viewBox="0 0 400 80" fill="none" aria-hidden="true"> <UiOiseau nom="frise" class="footer-shadok-pattern" />
<g transform="translate(20,10)">
<ellipse cx="15" cy="25" rx="12" ry="14" fill="currentColor" opacity="0.2"/>
<circle cx="15" cy="10" r="7" fill="currentColor" opacity="0.16"/>
<line x1="10" y1="38" x2="8" y2="55" stroke="currentColor" stroke-width="1.5" opacity="0.15"/>
<line x1="20" y1="38" x2="22" y2="55" stroke="currentColor" stroke-width="1.5" opacity="0.15"/>
</g>
<g transform="translate(80,15)">
<ellipse cx="15" cy="22" rx="10" ry="12" fill="currentColor" opacity="0.16"/>
<circle cx="15" cy="8" r="6" fill="currentColor" opacity="0.13"/>
<line x1="10" y1="33" x2="8" y2="48" stroke="currentColor" stroke-width="1.5" opacity="0.12"/>
<line x1="20" y1="33" x2="22" y2="48" stroke="currentColor" stroke-width="1.5" opacity="0.12"/>
</g>
<g transform="translate(140,8)">
<ellipse cx="15" cy="25" rx="11" ry="13" fill="currentColor" opacity="0.18"/>
<circle cx="15" cy="10" r="6.5" fill="currentColor" opacity="0.15"/>
<line x1="10" y1="37" x2="7" y2="54" stroke="currentColor" stroke-width="1.5" opacity="0.14"/>
<line x1="20" y1="37" x2="23" y2="54" stroke="currentColor" stroke-width="1.5" opacity="0.14"/>
</g>
<g transform="translate(210,18)">
<ellipse cx="15" cy="20" rx="10" ry="11" fill="currentColor" opacity="0.14"/>
<circle cx="15" cy="7" r="5.5" fill="currentColor" opacity="0.11"/>
<line x1="10" y1="30" x2="9" y2="44" stroke="currentColor" stroke-width="1.5" opacity="0.1"/>
<line x1="20" y1="30" x2="21" y2="44" stroke="currentColor" stroke-width="1.5" opacity="0.1"/>
</g>
<g transform="translate(270,12)">
<ellipse cx="15" cy="24" rx="12" ry="14" fill="currentColor" opacity="0.18"/>
<circle cx="15" cy="9" r="7" fill="currentColor" opacity="0.15"/>
<line x1="10" y1="37" x2="7" y2="55" stroke="currentColor" stroke-width="1.5" opacity="0.14"/>
<line x1="20" y1="37" x2="23" y2="55" stroke="currentColor" stroke-width="1.5" opacity="0.14"/>
</g>
<g transform="translate(340,16)">
<ellipse cx="15" cy="22" rx="10" ry="12" fill="currentColor" opacity="0.16"/>
<circle cx="15" cy="8" r="6" fill="currentColor" opacity="0.13"/>
<line x1="10" y1="33" x2="8" y2="48" stroke="currentColor" stroke-width="1.5" opacity="0.12"/>
<line x1="20" y1="33" x2="22" y2="48" stroke="currentColor" stroke-width="1.5" opacity="0.12"/>
</g>
</svg>
<div class="container-content px-4 py-8 relative z-1"> <div class="container-content px-4 py-8 relative z-1">
<div class="flex flex-col items-center gap-4 md:flex-row md:justify-between"> <div class="flex flex-col items-center gap-4 md:flex-row md:justify-between">
<!-- Credits --> <!-- Credits -->
@@ -47,9 +10,9 @@
</p> </p>
<!-- Links --> <!-- Links -->
<nav class="flex items-center gap-4" aria-label="Liens du pied de page"> <nav v-if="links.length" class="flex items-center gap-4" aria-label="Liens du pied de page">
<NuxtLink <NuxtLink
v-for="link in site?.footer.links" v-for="link in links"
:key="link.to" :key="link.to"
:to="link.to" :to="link.to"
class="text-sm text-[hsl(var(--color-text)/0.4)] transition-colors hover:text-[hsl(var(--color-text)/0.7)]" class="text-sm text-[hsl(var(--color-text)/0.4)] transition-colors hover:text-[hsl(var(--color-text)/0.7)]"
@@ -64,6 +27,14 @@
<script setup lang="ts"> <script setup lang="ts">
const { data: site } = await useSiteContent() const { data: site } = await useSiteContent()
const router = useRouter()
/** The footer links of site.yml, without those to a page the site does not have yet: the link
* to /mentions-legales (03/11: no such page, its text is still to be written) shows again by
* itself once the page exists */
interface FooterLink { label: string, to: string }
const links = computed<FooterLink[]>(() => (((site.value as any)?.footer?.links ?? []) as FooterLink[])
.filter(l => /^(https?:|mailto:)/.test(l.to) || router.resolve(l.to).matched.length > 0))
</script> </script>
<style scoped> <style scoped>
@@ -72,13 +43,14 @@ const { data: site } = await useSiteContent()
overflow: hidden; overflow: hidden;
} }
/* The whole frieze fits in the footer (mask: contain), never cropped; pale under the links */
.footer-shadok-pattern { .footer-shadok-pattern {
position: absolute; position: absolute;
bottom: 0; top: 0.75rem;
left: 0; left: 0;
width: 100%; width: 100%;
height: auto; height: calc(100% - 0.75rem);
opacity: 1; opacity: 0.12;
pointer-events: none; pointer-events: none;
color: hsl(var(--color-primary)); color: hsl(var(--color-primary));
} }
+88 -23
View File
@@ -2,29 +2,37 @@
<header class="sticky top-0 z-40 border-b border-[hsl(var(--color-text)/0.08)] bg-[hsl(var(--color-bg)/0.85)] backdrop-blur-xl"> <header class="sticky top-0 z-40 border-b border-[hsl(var(--color-text)/0.08)] bg-[hsl(var(--color-bg)/0.85)] backdrop-blur-xl">
<div class="container-content flex h-[var(--header-height)] items-center justify-between px-4"> <div class="container-content flex h-[var(--header-height)] items-center justify-between px-4">
<!-- Logo --> <!-- Logo -->
<NuxtLink to="/" class="logo-link flex items-center gap-2.5"> <NuxtLink to="/" class="logo-link flex items-center" :aria-label="site?.identity.name || 'Le librodrome'">
<svg class="logo-icon" viewBox="0 0 64 80" fill="none" aria-hidden="true"> <UiLogotype class="logo-lockup" />
<path d="M38 8 C28 6 18 10 18 20 C18 28 26 32 34 34 C42 36 48 40 48 48 C48 52 46 55 42 57 L44 40 C44 36 40 32 34 30 C28 28 22 24 22 18 C22 14 24 11 28 10Z" fill="currentColor" opacity="0.9"/>
<path d="M26 72 C36 74 46 70 46 60 C46 52 38 48 30 46 C22 44 16 40 16 32 C16 28 18 25 22 23 L20 40 C20 44 24 48 30 50 C36 52 42 56 42 62 C42 66 40 69 36 70Z" fill="currentColor" opacity="0.9"/>
<path d="M20 16 C20 8 28 4 36 6 C42 8 46 14 44 20" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" fill="none" opacity="0.7"/>
<path d="M44 64 C44 72 36 76 28 74 C22 72 18 66 20 60" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" fill="none" opacity="0.7"/>
<path d="M36 4 Q42 2 46 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.5"/>
<path d="M28 76 Q22 78 18 74" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.5"/>
</svg>
<span class="logo-text">{{ site?.identity.name }}</span>
</NuxtLink> </NuxtLink>
<!-- Desktop navigation --> <!-- Desktop navigation -->
<nav class="hidden md:flex items-center gap-1" aria-label="Navigation principale"> <nav class="nav-main hidden md:flex items-center gap-1" aria-label="Navigation principale">
<!-- Autonomie : prefix + axis buttons -->
<span class="nav-prefix">Autonomie&thinsp;:</span>
<NuxtLink <NuxtLink
v-for="item in site?.navigation" v-for="(item, i) in axes"
:key="item.to" :key="item.to"
:to="item.to" :to="item.to"
class="btn-ghost text-sm" class="btn-ghost text-sm nav-axis"
:style="{ '--hue': `var(${AXIS_HUES[i] ?? '--color-primary'})` }"
active-class="!text-[hsl(var(--color-text))] bg-[hsl(var(--color-text)/0.06)]" active-class="!text-[hsl(var(--color-text))] bg-[hsl(var(--color-text)/0.06)]"
> >
{{ item.label }} {{ item.label }}
</NuxtLink> </NuxtLink>
<!-- Separator + extra nav -->
<span class="nav-sep" />
<NuxtLink
v-for="item in extra"
:key="item.to"
:to="item.to"
class="btn-ghost btn-ghost--muted text-sm"
active-class="!text-[hsl(var(--color-text))] bg-[hsl(var(--color-text)/0.06)]"
>
{{ item.label }}
</NuxtLink>
<UiPaletteSelector /> <UiPaletteSelector />
</nav> </nav>
@@ -39,13 +47,20 @@
</div> </div>
<!-- Mobile menu --> <!-- Mobile menu -->
<LayoutNavMobile v-model:open="isMobileMenuOpen" :nav="site?.navigation ?? []" /> <LayoutNavMobile v-model:open="isMobileMenuOpen" :nav="allNav" />
</header> </header>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const { data: site } = await useSiteContent() const { data: site } = await useSiteContent()
const isMobileMenuOpen = ref(false) const isMobileMenuOpen = ref(false)
const axes = computed(() => (site.value as any)?.navigation?.axes ?? [])
const extra = computed(() => (site.value as any)?.navigation?.extra ?? [])
const allNav = computed(() => [...axes.value, ...extra.value])
// Same order as navigation.axes in site.yml: numérique, économique, citoyenne
const AXIS_HUES = ['--axis-num', '--axis-eco', '--axis-cit']
</script> </script>
<style scoped> <style scoped>
@@ -56,18 +71,68 @@ const isMobileMenuOpen = ref(false)
opacity: 0.8; opacity: 0.8;
} }
.logo-icon { /* The validated lockup (studio DA, 27/10): the Geste already leans 25°, no rotation on hover */
width: 1.6rem; .logo-lockup {
height: 2rem; height: 2.3rem;
color: hsl(var(--color-primary)); width: auto;
flex-shrink: 0; flex-shrink: 0;
transition: transform 0.2s ease;
} }
.logo-text { .logo-link:hover .logo-lockup {
transform: scale(1.04);
}
@media (prefers-reduced-motion: reduce) {
.logo-lockup { transition: none; }
}
.nav-prefix {
font-family: var(--font-display); font-family: var(--font-display);
font-weight: 600; font-size: 0.92rem;
font-size: 1.15rem; font-weight: 500;
letter-spacing: 0.02em; font-style: italic;
color: hsl(var(--color-primary)); letter-spacing: 0.03em;
color: hsl(var(--color-text) / 0.4);
margin-right: 0.125rem;
}
.nav-axis::before {
content: '';
width: 0.45rem;
height: 0.45rem;
margin-right: 0.45rem;
border-radius: 50%;
background: hsl(var(--hue));
}
.nav-sep {
display: block;
width: 1px;
height: 1.25rem;
background: hsl(var(--color-text) / 0.12);
margin: 0 0.375rem;
}
.btn-ghost--muted {
color: hsl(var(--color-text) / 0.45);
}
/* From 768 to 1023 px the kit's logotype (166 px) and the whole navigation share the bar:
tighter buttons instead of an overflow; the logotype of the studio DA kit stays as it is */
@media (max-width: 1023px) {
.nav-main {
gap: 0.125rem;
}
.nav-axis,
.btn-ghost--muted {
padding-left: 0.5rem;
padding-right: 0.5rem;
}
.nav-sep {
margin: 0 0.25rem;
}
} }
</style> </style>
@@ -179,6 +179,7 @@ onClickOutside(widgetRef, () => {
.mini-player { .mini-player {
position: fixed; position: fixed;
bottom: 1rem; bottom: 1rem;
transition: bottom 0.3s ease;
right: max(1rem, calc((100vw - 80rem) / 2)); right: max(1rem, calc((100vw - 80rem) / 2));
z-index: 70; z-index: 70;
display: flex; display: flex;
@@ -186,6 +187,11 @@ onClickOutside(widgetRef, () => {
align-items: flex-end; align-items: flex-end;
} }
/* Book player open: stay above its navigation bar */
:global(html.book-player-open .mini-player) {
bottom: 5.5rem;
}
/* ═══════════════════════════════════════ /* ═══════════════════════════════════════
PILL PILL
═══════════════════════════════════════ */ ═══════════════════════════════════════ */
+2 -2
View File
@@ -3,7 +3,7 @@
<span <span
v-for="song in songs" v-for="song in songs"
:key="song.id" :key="song.id"
class="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary/70" class="inline-flex items-center gap-1 rounded-full border border-primary/20 bg-primary/10 px-2.5 py-1 text-xs font-medium text-primary/80"
> >
<div class="i-lucide-music h-2.5 w-2.5" /> <div class="i-lucide-music h-2.5 w-2.5" />
{{ song.title }} {{ song.title }}
@@ -17,7 +17,7 @@ const props = defineProps<{
}>() }>()
const bookData = useBookData() const bookData = useBookData()
await bookData.init() bookData.init()
const songs = computed(() => bookData.getChapterSongs(props.chapterSlug)) const songs = computed(() => bookData.getChapterSongs(props.chapterSlug))
</script> </script>
File diff suppressed because one or more lines are too long
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
// One of the 61 birds (studio DA, validated 2026-10-28), drawn from /images/oiseaux/<nom>.svg as a
// mask filled with currentColor : it takes the colour the page gives it, like the shadoks did.
// The classes of the figure it replaces keep its place, size, opacity and animation.
import { OISEAUX } from '~/utils/oiseaux'
const props = defineProps<{ nom: string }>()
const o = computed(() => OISEAUX[props.nom])
</script>
<template>
<span
v-if="o"
class="oiseau"
aria-hidden="true"
:style="{ aspectRatio: o.ratio, '--oiseau': `url(/images/oiseaux/${props.nom}.svg)` }"
/>
</template>
<style scoped>
.oiseau {
display: block;
background-color: currentColor;
-webkit-mask: var(--oiseau) center / contain no-repeat;
mask: var(--oiseau) center / contain no-repeat;
}
</style>
+170
View File
@@ -0,0 +1,170 @@
<script setup lang="ts">
// A bird of the librodrome placed on its block (composition of 03/11, docs/consignes-yvv.md):
// it stands on a real edge of the page, next to the content its trade is about.
// The host block is the positioning context: position relative, overflow visible.
//
// Poses
// perche on the block's top edge (bottom: 100%), x with left / right
// marge-g beside the block, on its left; feet at the block's bottom, raised by `bottom`
// marge-d the same, on its right
// derriere large and pale, behind the block (z-index -1): only what sticks out shows.
// Needs a stacking context above the block (isolation: isolate) and a block
// that creates none itself (no z-index, transform, opacity, filter).
// libre placed only by left / right / top / bottom (an empty area next to its block)
//
// `bout`: a perched bird at the end (or the start) of a line of a title. The component is then a
// zero-size anchor in the text, at its baseline, written right after the text (the gap is `left`,
// 12 px by default) or right before it (the gap is `right`): the bird follows the end of the text
// at every width, wherever the line breaks (rule e, docs/da-oiseaux.md).
//
// Birds in the margin, behind a block or free need room around the column: they show from
// 1200 px wide, the perched ones from 768 px (`seuil` overrides, for instance 1024 for a perched
// bird whose block has no void above it until the grid splits in two); under 768 px, only a
// bird marked `mobile`.
// A perched bird needs, above its edge, a void at least as tall as itself (DA rule of 03/11);
// where blocks stand close, the bird goes in the margin or behind the block.
//
// Drawing rules of the studio DA (kit README, §2.5): one colour (currentColor), proportions
// intact and never cropped, 72 px wide at least, opacity 0.18-0.28 next to text and 0.10-0.14
// large behind a card, mirror allowed except for the calligraphe and the typographe.
// Birds standing on an edge do not move; only the one behind a card drifts a little (Yvv, 03/11).
import { OISEAUX } from '~/utils/oiseaux'
type Pose = 'perche' | 'marge-g' | 'marge-d' | 'derriere' | 'libre'
const props = withDefaults(defineProps<{
nom: string
pose?: Pose
/** Width in px: S 72-90, M 108-156, L 200-250 */
w?: number
left?: string
right?: string
top?: string
bottom?: string
/** Gap between the block and a bird in the margin */
ecart?: string
retourne?: boolean
/** Shown under 768 px (one bird per page at most) */
mobile?: boolean
/** Shown under 768 px only: the phone place of a bird placed elsewhere on wider screens */
mobileSeul?: boolean
/** Narrowest window (px) where the bird shows: 1200 by default for the margin, behind
* and free poses, 768 for a perched bird */
seuil?: 768 | 1024 | 1200
ton?: 'primary' | 'accent'
op?: number
/** At the end (or the start) of a line of text: written in the text, without a space */
bout?: boolean
}>(), { pose: 'perche', w: 80, ecart: '2.5rem', ton: 'primary' })
/** These two write and compose from left to right: never mirrored */
const NO_MIRROR = new Set(['calligraphe', 'typographe'])
const sol = computed(() => OISEAUX[props.nom]?.sol ?? 5)
const style = computed(() => {
const s: Record<string, string> = { 'width': `${Math.max(72, props.w)}px`, '--sol': `${sol.value}%` }
if (props.pose === 'perche') s.bottom = props.bout ? '0px' : '100%'
if (props.bout && props.left === undefined && props.right === undefined) s.left = '12px'
if (props.pose === 'marge-g') s.right = `calc(100% + ${props.ecart})`
if (props.pose === 'marge-d') s.left = `calc(100% + ${props.ecart})`
if (props.pose === 'marge-g' || props.pose === 'marge-d') s.bottom = '0px'
for (const k of ['left', 'right', 'top', 'bottom'] as const) {
if (props[k] !== undefined) s[k] = props[k]!
}
if (props.op !== undefined) s.opacity = String(props.op)
return s
})
const classes = computed(() => [
'oiseau-pose',
`oiseau-pose--${props.pose}`,
{
'oiseau-pose--retourne': props.retourne && !NO_MIRROR.has(props.nom),
'oiseau-pose--mobile': props.mobile || props.mobileSeul,
'oiseau-pose--mobile-seul': props.mobileSeul,
'oiseau-pose--large': (props.seuil ?? (props.pose === 'perche' ? 768 : 1200)) >= 1200,
'oiseau-pose--moyen': props.seuil === 1024,
'oiseau-pose--accent': props.ton === 'accent',
},
])
</script>
<template>
<span v-if="bout" class="oiseau-bout"><UiOiseau :nom="nom" :class="classes" :style="style" :data-oiseau="nom" :data-pose="pose" /></span>
<UiOiseau v-else :nom="nom" :class="classes" :style="style" :data-oiseau="nom" :data-pose="pose" />
</template>
<style scoped>
/* The anchor of a bird at the end of a line: no size, on the text's baseline */
.oiseau-bout {
position: relative;
display: inline-block;
width: 0;
height: 0;
vertical-align: baseline;
}
.oiseau-pose {
position: absolute;
z-index: 1;
pointer-events: none;
color: hsl(var(--color-primary));
opacity: 0.24;
/* The drawing's ground line lands on the edge */
transform: translateY(var(--sol));
}
.oiseau-pose--accent {
color: hsl(var(--color-accent));
}
.oiseau-pose--perche {
opacity: 0.28;
}
.oiseau-pose--derriere {
z-index: -1;
opacity: 0.12;
animation: oiseau-derive 12s ease-in-out infinite alternate;
}
.oiseau-pose--retourne {
scale: -1 1;
}
@keyframes oiseau-derive {
from { translate: 0 0; }
to { translate: 0 -6px; }
}
@media (max-width: 1199px) {
.oiseau-pose--large {
display: none;
}
}
@media (max-width: 1023px) {
.oiseau-pose--moyen {
display: none;
}
}
@media (min-width: 768px) {
.oiseau-pose--mobile-seul {
display: none;
}
}
@media (max-width: 767px) {
.oiseau-pose:not(.oiseau-pose--mobile) {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.oiseau-pose--derriere {
animation: none;
}
}
</style>
File diff suppressed because one or more lines are too long
+69
View File
@@ -0,0 +1,69 @@
<template>
<span v-if="label" class="status" :class="`status--${tone}`">
<span class="status-dot" aria-hidden="true" />
<span>{{ label }}</span>
<span v-if="since" class="status-since">· {{ since }}</span>
</span>
</template>
<script setup lang="ts">
// Project maturity, as written in YAML: `status: En production` (+ `since`),
// `status: En développement`, or the legacy `gestation: true`.
const props = defineProps<{
status?: string
gestation?: boolean
since?: string
}>()
const label = computed(() => props.status || (props.gestation ? 'En gestation' : ''))
const tone = computed(() => {
const s = label.value.toLowerCase()
if (s.includes('production')) return 'prod'
if (s.includes('développement') || s.includes('developpement')) return 'dev'
return 'gest'
})
</script>
<style scoped>
.status {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.15rem 0.6rem;
border-radius: 20px;
font-family: var(--font-mono);
font-size: 0.68rem;
font-weight: 500;
line-height: 1.5;
white-space: nowrap;
vertical-align: middle;
color: hsl(var(--color-text) / 0.75);
background: hsl(var(--color-text) / 0.06);
}
.status-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
flex-shrink: 0;
}
/* En production : plein */
.status--prod .status-dot { background: hsl(var(--axis-cit)); }
.status--prod { color: hsl(var(--axis-cit)); background: hsl(var(--axis-cit) / 0.1); }
/* En développement : demi-plein */
.status--dev .status-dot {
background: linear-gradient(to top, hsl(var(--axis-eco)) 50%, transparent 50%);
box-shadow: inset 0 0 0 1.5px hsl(var(--axis-eco));
}
.status--dev { color: hsl(var(--axis-eco)); background: hsl(var(--axis-eco) / 0.1); }
/* En gestation : cercle */
.status--gest .status-dot { box-shadow: inset 0 0 0 1.5px hsl(var(--color-text) / 0.45); }
.status-since {
opacity: 0.75;
}
</style>
+22 -16
View File
@@ -2,9 +2,12 @@ import type { Song } from '~/types/song'
let audio: HTMLAudioElement | null = null let audio: HTMLAudioElement | null = null
let animationFrameId: number | null = null let animationFrameId: number | null = null
// Track which milestones have been fired for the current song
const firedMilestones = new Set<number>()
export function useAudioPlayer() { export function useAudioPlayer() {
const store = usePlayerStore() const store = usePlayerStore()
const { trackAudioPlay, trackAudioComplete, trackAudioProgress } = useTracking()
function getAudio(): HTMLAudioElement { function getAudio(): HTMLAudioElement {
if (!audio) { if (!audio) {
@@ -17,6 +20,10 @@ export function useAudioPlayer() {
}) })
audio.addEventListener('ended', () => { audio.addEventListener('ended', () => {
if (store.currentSong) {
trackAudioComplete(store.currentSong.id, store.currentSong.title)
}
firedMilestones.clear()
const next = store.nextSong() const next = store.nextSong()
if (next) { if (next) {
loadAndPlay(next) loadAndPlay(next)
@@ -36,6 +43,16 @@ export function useAudioPlayer() {
const update = () => { const update = () => {
if (audio && !audio.paused) { if (audio && !audio.paused) {
store.setCurrentTime(audio.currentTime) store.setCurrentTime(audio.currentTime)
// Track progress milestones (25 / 50 / 75 %)
if (audio.duration > 0 && store.currentSong) {
const pct = (audio.currentTime / audio.duration) * 100
for (const milestone of [25, 50, 75] as const) {
if (pct >= milestone && !firedMilestones.has(milestone)) {
firedMilestones.add(milestone)
trackAudioProgress(store.currentSong.id, milestone)
}
}
}
} }
animationFrameId = requestAnimationFrame(update) animationFrameId = requestAnimationFrame(update)
} }
@@ -52,34 +69,23 @@ export function useAudioPlayer() {
async function loadAndPlay(song: Song) { async function loadAndPlay(song: Song) {
const el = getAudio() const el = getAudio()
store.setSong(song) store.setSong(song)
firedMilestones.clear()
// Try OGG first, fall back to MP3 // Only MP3 files are published (no .ogg on disk nor in production):
const oggPath = song.file.replace(/\.mp3$/, '.ogg') // asking for OGG first cost a 404 and a delay on every track.
const canOgg = el.canPlayType('audio/ogg; codecs=vorbis') el.src = song.file
el.src = canOgg ? oggPath : song.file
el.volume = store.volume el.volume = store.volume
try { try {
await el.play() await el.play()
store.play() store.play()
startTimeUpdate() startTimeUpdate()
} trackAudioPlay(song.id, song.title)
catch {
// If OGG failed, try MP3
if (el.src !== song.file) {
el.src = song.file
try {
await el.play()
store.play()
startTimeUpdate()
} }
catch (err) { catch (err) {
console.error('Playback failed:', err) console.error('Playback failed:', err)
} }
} }
}
}
function pause() { function pause() {
getAudio().pause() getAudio().pause()
+13 -1
View File
@@ -1,5 +1,5 @@
import type { Song } from '~/types/song' import type { Song } from '~/types/song'
import type { ChapterSongLink, BookConfig } from '~/types/book' import type { ChapterSongLink, ChapterPageLink, BookConfig } from '~/types/book'
let _configCache: BookConfig | null = null let _configCache: BookConfig | null = null
@@ -13,9 +13,11 @@ async function loadConfig(): Promise<BookConfig> {
author: parsed.book.author, author: parsed.book.author,
description: parsed.book.description, description: parsed.book.description,
coverImage: parsed.book.coverImage, coverImage: parsed.book.coverImage,
pdfFile: parsed.book.pdfFile,
chapters: [], chapters: [],
songs: parsed.songs as Song[], songs: parsed.songs as Song[],
chapterSongs: parsed.chapterSongs as ChapterSongLink[], chapterSongs: parsed.chapterSongs as ChapterSongLink[],
chapterPages: (parsed.chapterPages ?? []) as ChapterPageLink[],
defaultPlaylistOrder: parsed.defaultPlaylistOrder as string[], defaultPlaylistOrder: parsed.defaultPlaylistOrder as string[],
} }
@@ -76,6 +78,14 @@ export function useBookData() {
return link?.chapterSlug return link?.chapterSlug
} }
function getChapterPage(chapterSlug: string): number | undefined {
return config.value?.chapterPages.find(cp => cp.chapterSlug === chapterSlug)?.page
}
function getPdfUrl(): string {
return config.value?.pdfFile || '/pdf/une-economie-du-don.pdf'
}
function getBookMeta() { function getBookMeta() {
if (!config.value) return null if (!config.value) return null
return { return {
@@ -98,5 +108,7 @@ export function useBookData() {
getChapterForSong, getChapterForSong,
getPlaylistOrder, getPlaylistOrder,
getBookMeta, getBookMeta,
getChapterPage,
getPdfUrl,
} }
} }
+3 -9
View File
@@ -1,16 +1,10 @@
export function useGrateWizard() { export function useGrateWizard() {
const appConfig = useAppConfig() const appConfig = useAppConfig()
const { url, popup } = appConfig.gratewizard as { url: string; popup: { width: number; height: number } } const { url } = appConfig.gratewizard as { url: string; popup: { width: number; height: number } }
function launch(e?: Event) { function launch(e?: Event) {
const left = Math.round((window.screen.width - popup.width) / 2) window.open(url, '_blank', 'noopener,noreferrer')
const top = Math.round((window.screen.height - popup.height) / 2) e?.preventDefault()
const win = window.open(
url,
'grateWizard',
`width=${popup.width},height=${popup.height},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no,scrollbars=yes,resizable=yes`,
)
if (win) e?.preventDefault()
} }
return { url, launch } return { url, launch }
+151
View File
@@ -0,0 +1,151 @@
// Normalised view of the home content, shared by the three home variants.
// Everything comes from YAML (site/pages/home.yml, site/site.yml,
// site/pages/evenement.yml, site/pages/numerique/cloud-libre.yml) — no copy here.
export type AxisKey = 'numerique' | 'economie' | 'politique'
export type StackStatus = 'service' | 'catalogue' | 'avenir'
export interface HomeAction {
id: string
label: string
icon: string
highlight?: boolean
secondary?: boolean
to?: string
href?: string
/** Short status line shown under the button (e.g. the v1 in production) */
note?: string
}
export interface HomeTool {
label: string
description: string
to?: string
href?: string
gestation?: boolean
/** « En production », « En développement »… — wins over `gestation` */
status?: string
since?: string
icon?: string
presentation?: { title: string, text: string }
actions?: HomeAction[]
}
export interface HomeStack {
role: string
name?: string
status: StackStatus
url?: string
}
export interface HomeAxis {
key: AxisKey
/** Short label as used in the navigation ("numérique", "économique", "citoyenne") */
short: string
title: string
icon: string
to: string
/** What is "le plus en amont" on this axis (hero.axes) */
lever: string
promise: string
/** CSS custom property holding the axis hue */
hue: string
items: HomeTool[]
}
const AXIS_META: Record<AxisKey, { heroLabel: string, to: string, hue: string, short: string }> = {
numerique: { heroLabel: 'numérique', to: '/numerique', hue: '--axis-num', short: 'numérique' },
economie: { heroLabel: 'économie', to: '/economique', hue: '--axis-eco', short: 'économique' },
politique: { heroLabel: 'citoyenne', to: '/citoyenne', hue: '--axis-cit', short: 'citoyenne' },
}
export const AXIS_ORDER: AxisKey[] = ['numerique', 'economie', 'politique']
export async function useHomeData() {
const [{ data: home }, { data: site }, { data: event }, { data: cloud }] = await Promise.all([
usePageContent('home'),
useSiteContent(),
usePageContent('evenement'),
usePageContent('numerique/cloud-libre'),
])
const hero = computed(() => (home.value as any)?.hero ?? {})
const axes = computed<HomeAxis[]>(() => {
const raw = (home.value as any)?.axes ?? {}
const levers: { label: string, value: string }[] = hero.value.axes ?? []
return AXIS_ORDER.filter(k => raw[k]).map((key) => {
const meta = AXIS_META[key]
return {
key,
short: meta.short,
title: raw[key].title,
icon: raw[key].icon,
to: meta.to,
lever: levers.find(l => l.label === meta.heroLabel)?.value ?? '',
promise: raw[key].promise ?? '',
hue: meta.hue,
items: raw[key].items ?? [],
}
})
})
const lpk = computed(() => (site.value as any)?.lpk as
{ name: string, text: string, role: string, to: string } | undefined)
/** Bouquet sweetHome, flattened — the stacks served by LaPlanK */
const bouquet = computed(() => {
const section = ((cloud.value as any)?.sections ?? []).find((s: any) => s.type === 'territoire')
const b = section?.bouquet
if (!b) return null
const stacks: (HomeStack & { category: string })[] = []
for (const cat of b.categories ?? []) {
for (const item of cat.items ?? []) {
if (typeof item === 'string') continue
stacks.push({ ...item, category: cat.label })
}
}
return {
title: b.title as string,
statuses: (b.statuses ?? {}) as Record<StackStatus, string>,
categories: (b.categories ?? []) as { label: string, icon: string, items: HomeStack[] }[],
stacks,
inService: stacks.filter(s => s.status === 'service' && s.name),
}
})
const evenement = computed(() => {
const e = event.value as any
const teaser = (home.value as any)?.evenement
if (!e && !teaser) return null
const politique = (e?.axes ?? []).find((a: any) => a.label === 'Politique')
return {
to: teaser?.to ?? '/evenement',
title: e?.title ?? teaser?.title,
teaser: teaser ? `${teaser.title} ${teaser.subtitle}` : '',
subtitle: e?.subtitle ?? '',
tagline: e?.tagline ?? '',
leitmotiv: e?.leitmotiv ?? '',
description: e?.description ?? '',
gestation: e?.gestation ?? teaser?.gestation,
config: (e?.config ?? []) as { icon: string, label: string, detail: string }[],
espaces: (e?.espaces ?? []) as { icon: string, label: string, desc: string }[],
/** "Autonomie d'un bassin de vie — accessible et reproductible" */
reproduction: (politique?.items?.[0] as string) ?? '',
}
})
return {
home,
hero,
axes,
lpk,
bouquet,
evenement,
book: computed(() => (home.value as any)?.book),
format: computed(() => (home.value as any)?.format as { title: string, text: string } | undefined),
toolboxTitle: computed(() => (home.value as any)?.toolbox?.title as string | undefined),
consoleLabels: computed(() => (home.value as any)?.console as { prefix: string, top: string, bottom: string } | undefined),
gratewizard: computed(() => (home.value as any)?.gratewizard),
}
}
+55
View File
@@ -0,0 +1,55 @@
/**
* Tracks scroll depth milestones (25 / 50 / 75 / 100 %) on each page.
* Also intercepts external link clicks and tracks them.
*
* Usage: call once in default.vue layout.
*/
export function useScrollTracking() {
const route = useRoute()
const { trackScrollDepth, trackExternalLink } = useTracking()
// ── Scroll depth ────────────────────────────────────────────────────────
const fired = new Set<number>()
function onScroll() {
const el = document.documentElement
const scrolled = el.scrollTop + el.clientHeight
const total = el.scrollHeight
if (total <= el.clientHeight) return
const pct = (scrolled / total) * 100
for (const milestone of [25, 50, 75, 100] as const) {
if (pct >= milestone && !fired.has(milestone)) {
fired.add(milestone)
trackScrollDepth(route.path, milestone)
}
}
}
// Reset fired milestones on route change
watch(() => route.path, () => fired.clear())
// ── External link tracking ───────────────────────────────────────────────
function onLinkClick(e: MouseEvent) {
const target = (e.target as HTMLElement).closest('a')
if (!target) return
const href = target.getAttribute('href') || ''
if (!href.startsWith('http') && !href.startsWith('//')) return
try {
const url = new URL(href)
if (url.hostname === 'librodrome.org') return
trackExternalLink(href, target.textContent?.trim() || '')
}
catch { /* invalid URL, ignore */ }
}
onMounted(() => {
window.addEventListener('scroll', onScroll, { passive: true })
document.addEventListener('click', onLinkClick)
})
onUnmounted(() => {
window.removeEventListener('scroll', onScroll)
document.removeEventListener('click', onLinkClick)
})
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Applique toutes les balises SEO (og:*, Twitter Cards, canonical, description)
* à partir du contenu YAML d'une page.
*
* Usage dans les pages :
* useSeoPage({ title: content.value?.meta?.title, description: content.value?.description })
*
* L'og:image par défaut est /og-default.png (logo §).
* Chaque section peut surcharger avec son propre image via le champ seo.image du YAML.
*/
export function useSeoPage(opts: {
title?: string | null
description?: string | null
image?: string | null
type?: 'website' | 'article' | 'book'
}) {
const config = useRuntimeConfig()
const route = useRoute()
const siteUrl = (config.public.siteUrl as string) || 'https://librodrome.org'
const title = opts.title || 'Le Librodrome'
const description = opts.description
|| 'Autonomie numérique, économique et citoyenne. Un livre et des chansons sur l\'économie du don.'
const rawImage = opts.image || '/og-default.png?v=3'
const image = rawImage.startsWith('http') ? rawImage : `${siteUrl}${rawImage}`
const canonical = `${siteUrl}${route.path}`
const type = opts.type || 'website'
useSeoMeta({
// Open Graph
ogTitle: title,
ogDescription: description,
ogImage: image,
ogImageWidth: 1200,
ogImageHeight: 630,
ogUrl: canonical,
ogType: type,
ogSiteName: 'Le Librodrome',
ogLocale: 'fr_FR',
// Twitter Cards
twitterCard: 'summary_large_image',
twitterTitle: title,
twitterDescription: description,
twitterImage: image,
// Standard
description,
})
useHead({
link: [{ rel: 'canonical', href: canonical }],
})
}
+6
View File
@@ -14,6 +14,12 @@ interface SiteContent {
credits: string credits: string
links: NavItem[] links: NavItem[]
} }
lpk?: {
name: string
text: string
role: string
to: string
}
gratewizard: { gratewizard: {
url: string url: string
popup: { popup: {
+96
View File
@@ -0,0 +1,96 @@
/**
* Umami analytics wrapper — safe server-side, no-op when not configured.
*
* Events convention:
* audio:play | audio:pause | audio:complete | audio:progress
* pdf:open | pdf:close
* player:open | player:chapter | player:mode
* scroll:depth
* link:external
* cta:click
*/
export function useTracking() {
const runtimeConfig = useRuntimeConfig()
const enabled = !!runtimeConfig.public.umamiWebsiteId
// ── Core ────────────────────────────────────────────────────────────────
function track(event: string, data?: Record<string, unknown>) {
if (!import.meta.client || !enabled) return
const umami = (window as Record<string, unknown>).umami as
| { track: (event: string, data?: unknown) => void }
| undefined
umami?.track(event, data)
}
// ── Audio ────────────────────────────────────────────────────────────────
function trackAudioPlay(songId: string, songTitle: string, context?: string) {
track('audio:play', { song_id: songId, song_title: songTitle, context })
}
function trackAudioPause(songId: string, progressPct: number) {
track('audio:pause', { song_id: songId, progress_pct: progressPct })
}
function trackAudioComplete(songId: string, songTitle: string) {
track('audio:complete', { song_id: songId, song_title: songTitle })
}
/** Fired at 25 / 50 / 75 % milestones */
function trackAudioProgress(songId: string, milestone: 25 | 50 | 75) {
track('audio:progress', { song_id: songId, milestone })
}
// ── PDF ──────────────────────────────────────────────────────────────────
function trackPdfOpen(trigger?: string) {
track('pdf:open', { trigger })
}
function trackPdfClose(pagesVisited: number, durationMs: number) {
track('pdf:close', { pages_visited: pagesVisited, duration_ms: durationMs })
}
// ── BookPlayer ───────────────────────────────────────────────────────────
function trackPlayerOpen(trigger?: string) {
track('player:open', { trigger })
}
function trackPlayerChapter(chapterSlug: string) {
track('player:chapter', { chapter_slug: chapterSlug })
}
function trackPlayerMode(mode: 'guided' | 'scroll') {
track('player:mode', { mode })
}
// ── Navigation & UX ──────────────────────────────────────────────────────
function trackScrollDepth(page: string, depth: 25 | 50 | 75 | 100) {
track('scroll:depth', { page, depth })
}
function trackExternalLink(url: string, label?: string) {
const route = useRoute()
track('link:external', { url, label, from_page: route.path })
}
function trackCta(label: string, target?: string) {
const route = useRoute()
track('cta:click', { label, target, from_page: route.path })
}
return {
track,
enabled,
trackAudioPlay,
trackAudioPause,
trackAudioComplete,
trackAudioProgress,
trackPdfOpen,
trackPdfClose,
trackPlayerOpen,
trackPlayerChapter,
trackPlayerMode,
trackScrollDepth,
trackExternalLink,
trackCta,
}
}
+45 -1
View File
@@ -1,15 +1,59 @@
<template> <template>
<div class="app-layout grid grid-cols-1 min-h-dvh"> <div class="app-layout grid grid-cols-1 min-h-dvh">
<LayoutTheHeader /> <LayoutTheHeader />
<main> <main class="app-main">
<slot /> <slot />
<!-- 益 Yì (Increase, #42) — sceau hexagramme (traits épais, coins nets, ~carré) -->
<svg class="app-seal" viewBox="0 0 112 105" fill="currentColor" aria-hidden="true">
<!-- Line 6 (top) — yang -->
<rect x="6" y="6" width="100" height="8"/>
<!-- Line 5 — yang -->
<rect x="6" y="23" width="100" height="8"/>
<!-- Line 4 — yin -->
<rect x="6" y="40" width="42" height="8"/>
<rect x="64" y="40" width="42" height="8"/>
<!-- Line 3 — yin -->
<rect x="6" y="57" width="42" height="8"/>
<rect x="64" y="57" width="42" height="8"/>
<!-- Line 2 — yin -->
<rect x="6" y="74" width="42" height="8"/>
<rect x="64" y="74" width="42" height="8"/>
<!-- Line 1 (bottom) — yang -->
<rect x="6" y="91" width="100" height="8"/>
</svg>
</main> </main>
<LayoutTheFooter /> <LayoutTheFooter />
</div> </div>
</template> </template>
<script setup lang="ts">
useScrollTracking()
</script>
<style scoped> <style scoped>
.app-layout { .app-layout {
grid-template-rows: auto 1fr auto; grid-template-rows: auto 1fr auto;
} }
/* The seal's block, between the page and the footer: a bird of a page that stands on the footer's
top rule takes its offset from here (evenement, the puppeteer) */
.app-main {
--seal-w: 44px;
--seal-mt: 2rem;
--seal-mb: 1rem;
--seal-block: calc(var(--seal-mt) + var(--seal-w) * 105 / 112 + var(--seal-mb));
}
/* === Seal — 益 Yì (Increase) === */
.app-seal {
display: block;
width: var(--seal-w);
/* its height follows the viewBox: width × 105 / 112 (in --seal-block) */
margin: var(--seal-mt) 1.5rem var(--seal-mb) auto;
color: hsl(var(--color-accent));
opacity: 0.28;
filter: drop-shadow(1px 1px 0.5px rgba(0,0,0,0.25))
drop-shadow(-0.5px -0.5px 0.5px rgba(255,255,255,0.15));
}
</style> </style>
+16 -49
View File
@@ -1,36 +1,14 @@
<template> <template>
<div class="relative overflow-hidden section-padding"> <div class="relative overflow-hidden section-padding">
<!-- Shadok philosopher: character sitting cross-legged, floating --> <div class="container-content relative mx-auto max-w-3xl">
<svg class="shadok-philosopher" viewBox="0 0 220 280" fill="none" aria-hidden="true"> <!-- In the margins, at the height of « La vision » and of the economic proposal -->
<!-- Body (round, serene) --> <UiOiseauPose nom="philosophe" pose="libre" left="calc(100% + 50px)" top="470px" :w="120" retourne />
<ellipse cx="110" cy="150" rx="48" ry="55" fill="currentColor" opacity="0.85"/> <UiOiseauPose nom="penseur" pose="libre" right="calc(100% + 60px)" top="720px" :w="100" />
<!-- Head -->
<ellipse cx="110" cy="82" rx="26" ry="25" fill="currentColor" opacity="0.8"/>
<!-- Closed eyes (meditating) -->
<path d="M96 80 Q100 84 105 80" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.4"/>
<path d="M115 80 Q119 84 124 80" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.4"/>
<!-- Serene smile -->
<path d="M102 93 Q110 98 118 93" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.3"/>
<!-- Arms resting on knees -->
<path d="M64 155 Q55 180 70 200" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" fill="none" opacity="0.6"/>
<path d="M156 155 Q165 180 150 200" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" fill="none" opacity="0.6"/>
<!-- Hands on knees -->
<circle cx="70" cy="200" r="5" fill="currentColor" opacity="0.4"/>
<circle cx="150" cy="200" r="5" fill="currentColor" opacity="0.4"/>
<!-- Crossed legs -->
<path d="M80 205 Q90 230 120 235" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" fill="none" opacity="0.6"/>
<path d="M140 205 Q130 230 100 235" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" fill="none" opacity="0.6"/>
<!-- Floating aura lines -->
<path d="M50 245 Q110 260 170 245" stroke="currentColor" stroke-width="1.5" stroke-dasharray="4 4" fill="none" opacity="0.25"/>
<path d="M60 255 Q110 268 160 255" stroke="currentColor" stroke-width="1" stroke-dasharray="3 5" fill="none" opacity="0.18"/>
<!-- Small sparkles around -->
<circle cx="55" cy="110" r="2.5" fill="currentColor" opacity="0.2"/>
<circle cx="170" cy="95" r="2" fill="currentColor" opacity="0.18"/>
<circle cx="160" cy="130" r="3" fill="currentColor" opacity="0.15"/>
</svg>
<div class="container-content mx-auto max-w-3xl">
<ContentRenderer v-if="page" :value="page" class="prose" /> <ContentRenderer v-if="page" :value="page" class="prose" />
<!-- Credit of the mascot (Yvv, word for word, 27/10) -->
<p class="oiseaux-credit">
N'ayant pas les moyens d'acquérir des droits, le personnage mascotte est une création qui fait un clin d'œil aux shadoks, avec respect et une grande affection, petit hommage en passant.
</p>
</div> </div>
</div> </div>
</template> </template>
@@ -40,8 +18,9 @@ definePageMeta({
layout: 'default', layout: 'default',
}) })
useHead({ useSeoPage({
title: 'À propos', title: 'À propos — Le Librodrome',
description: 'Le Librodrome : l\'histoire du projet, la démarche et les personnes derrière le livre et les outils d\'autonomie.',
}) })
const { data: page } = await useAsyncData('about', () => const { data: page } = await useAsyncData('about', () =>
@@ -50,23 +29,11 @@ const { data: page } = await useAsyncData('about', () =>
</script> </script>
<style scoped> <style scoped>
.shadok-philosopher {
position: absolute;
right: 3%;
bottom: 6%;
width: clamp(120px, 15vw, 220px);
opacity: 0.3;
pointer-events: none;
color: hsl(var(--color-primary));
animation: shadok-float-philosopher 11s ease-in-out infinite;
}
@keyframes shadok-float-philosopher { .oiseaux-credit {
0%, 100% { transform: translateY(0); } margin: 3rem 0 0;
50% { transform: translateY(-12px); } font-size: 0.8rem;
} line-height: 1.5;
color: hsl(var(--color-text) / 0.5);
@media (max-width: 768px) {
.shadok-philosopher { display: none; }
} }
</style> </style>
+18 -18
View File
@@ -209,7 +209,7 @@ async function save() {
.field-label { .field-label {
display: block; display: block;
font-size: 0.75rem; font-size: 0.75rem;
color: hsl(20 8% 50%); color: hsl(var(--color-text-muted));
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
} }
@@ -217,15 +217,15 @@ async function save() {
width: 100%; width: 100%;
padding: 0.5rem 0.625rem; padding: 0.5rem 0.625rem;
border-radius: 0.375rem; border-radius: 0.375rem;
border: 1px solid hsl(20 8% 18%); border: 1px solid hsl(var(--color-surface-light));
background: hsl(20 8% 6%); background: hsl(var(--color-bg));
color: white; color: hsl(var(--color-text));
font-size: 0.85rem; font-size: 0.85rem;
} }
.field-input:focus { .field-input:focus {
outline: none; outline: none;
border-color: hsl(12 76% 48% / 0.5); border-color: hsl(var(--color-primary) / 0.5);
} }
/* ── Song tags ── */ /* ── Song tags ── */
@@ -233,45 +233,45 @@ async function save() {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
border-radius: 9999px; border-radius: 9999px;
border: 1px solid hsl(20 8% 22%); border: 1px solid hsl(var(--color-surface-light));
transition: all 0.15s; transition: all 0.15s;
overflow: hidden; overflow: hidden;
} }
.song-tag:hover { .song-tag:hover {
border-color: hsl(12 76% 48% / 0.4); border-color: hsl(var(--color-primary) / 0.4);
} }
.song-tag--active { .song-tag--active {
border-color: hsl(12 76% 48% / 0.6); border-color: hsl(var(--color-primary) / 0.6);
background: hsl(12 76% 48% / 0.08); background: hsl(var(--color-primary) / 0.08);
} }
.song-tag--primary { .song-tag--primary {
border-color: hsl(45 90% 55%); border-color: hsl(var(--color-accent));
background: hsl(45 90% 55% / 0.08); background: hsl(var(--color-accent) / 0.08);
} }
.song-tag-label { .song-tag-label {
padding: 0.375rem 0.75rem; padding: 0.375rem 0.75rem;
background: none; background: none;
border: none; border: none;
color: hsl(20 8% 50%); color: hsl(var(--color-text-muted));
font-size: 0.8rem; font-size: 0.8rem;
cursor: pointer; cursor: pointer;
transition: color 0.15s; transition: color 0.15s;
} }
.song-tag--active .song-tag-label { .song-tag--active .song-tag-label {
color: hsl(12 76% 68%); color: hsl(var(--color-primary));
} }
.song-tag--primary .song-tag-label { .song-tag--primary .song-tag-label {
color: hsl(45 90% 65%); color: hsl(var(--color-accent));
} }
.song-tag-label:hover { .song-tag-label:hover {
color: white; color: hsl(var(--color-text));
} }
.song-star { .song-star {
@@ -281,16 +281,16 @@ async function save() {
padding: 0.375rem 0 0.375rem 0.625rem; padding: 0.375rem 0 0.375rem 0.625rem;
background: none; background: none;
border: none; border: none;
color: hsl(20 8% 30%); color: hsl(var(--color-text-muted));
cursor: pointer; cursor: pointer;
transition: color 0.15s; transition: color 0.15s;
} }
.song-star:hover { .song-star:hover {
color: hsl(45 90% 55%); color: hsl(var(--color-accent));
} }
.song-star--active { .song-star--active {
color: hsl(45 90% 55%); color: hsl(var(--color-accent));
} }
</style> </style>
+160 -20
View File
@@ -1,10 +1,34 @@
<template> <template>
<div> <div>
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<h1 class="font-display text-2xl font-bold text-white">Chapitres</h1> <h1 class="font-display text-2xl font-bold text-white">Livre &amp; chapitres</h1>
<AdminSaveButton :saving="saving" :saved="saved" @save="saveOrder" /> <AdminSaveButton :saving="saving" :saved="saved" @save="saveOrder" />
</div> </div>
<!-- PDF config -->
<div class="pdf-section">
<h2 class="font-display text-sm font-semibold text-white/60 mb-3">PDF du livre</h2>
<div class="flex items-end gap-3">
<div class="flex-1">
<label class="block text-xs text-white/40 mb-1">Chemin du fichier PDF</label>
<input
v-model="pdfPath"
class="admin-input w-full font-mono text-xs"
placeholder="/pdf/une-economie-du-don.pdf"
/>
</div>
<button class="save-pdf-btn" @click="savePdfConfig" :disabled="savingPdf">
<div v-if="savingPdf" class="i-lucide-loader-2 h-3.5 w-3.5 animate-spin" />
<div v-else-if="savedPdf" class="i-lucide-check h-3.5 w-3.5" />
<div v-else class="i-lucide-save h-3.5 w-3.5" />
{{ savedPdf ? 'Enregistré' : 'Enregistrer' }}
</button>
</div>
<p class="text-xs text-white/30 mt-2">
Uploadez le PDF via Médias, puis renseignez le chemin ici.
</p>
</div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div <div
v-for="(chapter, i) in chapters" v-for="(chapter, i) in chapters"
@@ -35,6 +59,20 @@
>{{ name }}</span> >{{ name }}</span>
</div> </div>
</div> </div>
<select
v-if="pdfOutline.length"
class="page-select"
:value="getChapterPage(chapter.slug) ?? ''"
@change="setChapterPage(chapter.slug, ($event.target as HTMLSelectElement).value)"
>
<option value="">— Page PDF —</option>
<option
v-for="entry in pdfOutline"
:key="`${entry.page}-${entry.title}`"
:value="entry.page"
>{{ '\u00A0\u00A0'.repeat(entry.level) }}{{ entry.title }} (p.{{ entry.page }})</option>
</select>
<span v-else class="text-xs text-white/30">…</span>
<button <button
class="delete-btn" class="delete-btn"
@click="removeChapter(chapter.slug)" @click="removeChapter(chapter.slug)"
@@ -91,6 +129,58 @@ const saved = ref(false)
const newTitle = ref('') const newTitle = ref('')
const newSlug = ref('') const newSlug = ref('')
// PDF path + outline
const pdfPath = ref(bookConfig.value?.book?.pdfFile ?? '/pdf/une-economie-du-don.pdf')
const savingPdf = ref(false)
const savedPdf = ref(false)
const pdfOutline = ref<Array<{ title: string; page: number; level: number }>>([])
const chapterPageMap = ref<Record<string, number | undefined>>({})
if (bookConfig.value?.chapterPages) {
for (const cp of bookConfig.value.chapterPages) {
chapterPageMap.value[cp.chapterSlug] = cp.page
}
}
function getChapterPage(slug: string): number | undefined {
return chapterPageMap.value[slug]
}
function setChapterPage(slug: string, val: string) {
const num = parseInt(val, 10)
if (num > 0) chapterPageMap.value[slug] = num
else delete chapterPageMap.value[slug]
}
// Charger le sommaire PDF côté client via l'API serveur
onMounted(() => {
$fetch<Array<{ title: string; page: number; level: number }>>('/api/admin/pdf-outline')
.then((data) => { pdfOutline.value = data })
.catch((err) => { console.warn('PDF outline load failed:', err) })
})
async function savePdfConfig() {
if (!bookConfig.value) return
savingPdf.value = true
savedPdf.value = false
try {
bookConfig.value.book.pdfFile = pdfPath.value
bookConfig.value.chapterPages = Object.entries(chapterPageMap.value)
.filter(([, page]) => page != null)
.map(([chapterSlug, page]) => ({ chapterSlug, page }))
await $fetch('/api/admin/content/config', {
method: 'PUT',
body: bookConfig.value,
})
savedPdf.value = true
setTimeout(() => { savedPdf.value = false }, 2000)
} finally {
savingPdf.value = false
}
}
// Drag & drop state // Drag & drop state
const dragIdx = ref<number | null>(null) const dragIdx = ref<number | null>(null)
const dropIdx = ref<number | null>(null) const dropIdx = ref<number | null>(null)
@@ -156,19 +246,52 @@ async function removeChapter(slug: string) {
</script> </script>
<style scoped> <style scoped>
.pdf-section {
margin-bottom: 1.5rem;
padding: 1rem;
border: 1px solid hsl(var(--color-surface-light));
border-radius: 0.5rem;
background: hsl(var(--color-bg));
}
.save-pdf-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
border: 1px solid hsl(var(--color-surface-light));
background: none;
color: hsl(var(--color-text-muted));
font-size: 0.8rem;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.save-pdf-btn:hover:not(:disabled) {
border-color: hsl(var(--color-primary) / 0.5);
color: hsl(var(--color-primary));
}
.save-pdf-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.chapter-item { .chapter-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border: 1px solid hsl(20 8% 14%); border: 1px solid hsl(var(--color-surface-light));
border-radius: 0.5rem; border-radius: 0.5rem;
transition: all 0.2s; transition: all 0.2s;
} }
.chapter-item:hover { .chapter-item:hover {
border-color: hsl(12 76% 48% / 0.3); border-color: hsl(var(--color-primary) / 0.3);
background: hsl(20 8% 6%); background: hsl(var(--color-bg));
} }
.chapter-item--dragging { .chapter-item--dragging {
@@ -176,13 +299,13 @@ async function removeChapter(slug: string) {
} }
.chapter-item--over { .chapter-item--over {
border-top: 2px solid hsl(12 76% 48%); border-top: 2px solid hsl(var(--color-primary));
} }
.drag-handle { .drag-handle {
cursor: grab; cursor: grab;
padding: 0.25rem; padding: 0.25rem;
color: hsl(20 8% 35%); color: hsl(var(--color-text-muted));
flex-shrink: 0; flex-shrink: 0;
} }
@@ -193,7 +316,7 @@ async function removeChapter(slug: string) {
.chapter-order { .chapter-order {
font-family: var(--font-mono, monospace); font-family: var(--font-mono, monospace);
font-size: 0.85rem; font-size: 0.85rem;
color: hsl(12 76% 48% / 0.5); color: hsl(var(--color-primary) / 0.5);
font-weight: 600; font-weight: 600;
width: 1.75rem; width: 1.75rem;
} }
@@ -205,13 +328,13 @@ async function removeChapter(slug: string) {
.chapter-title { .chapter-title {
display: block; display: block;
color: white; color: hsl(var(--color-text));
font-weight: 500; font-weight: 500;
text-decoration: none; text-decoration: none;
} }
.chapter-title:hover { .chapter-title:hover {
color: hsl(12 76% 68%); color: hsl(var(--color-primary));
} }
.chapter-songs { .chapter-songs {
@@ -225,9 +348,26 @@ async function removeChapter(slug: string) {
font-size: 0.65rem; font-size: 0.65rem;
padding: 0.1rem 0.5rem; padding: 0.1rem 0.5rem;
border-radius: 9999px; border-radius: 9999px;
background: hsl(12 76% 48% / 0.1); background: hsl(var(--color-primary) / 0.1);
color: hsl(12 76% 60%); color: hsl(var(--color-primary));
border: 1px solid hsl(12 76% 48% / 0.2); border: 1px solid hsl(var(--color-primary) / 0.2);
}
.page-select {
flex-shrink: 0;
max-width: 14rem;
padding: 0.25rem 0.4rem;
border-radius: 0.375rem;
border: 1px solid hsl(var(--color-surface-light));
background: hsl(var(--color-bg));
color: hsl(var(--color-text-muted));
font-size: 0.7rem;
cursor: pointer;
}
.page-select:focus {
outline: none;
border-color: hsl(var(--color-primary) / 0.5);
} }
.delete-btn { .delete-btn {
@@ -248,15 +388,15 @@ async function removeChapter(slug: string) {
.admin-input { .admin-input {
padding: 0.375rem 0.5rem; padding: 0.375rem 0.5rem;
border-radius: 0.375rem; border-radius: 0.375rem;
border: 1px solid hsl(20 8% 18%); border: 1px solid hsl(var(--color-surface-light));
background: hsl(20 8% 6%); background: hsl(var(--color-bg));
color: white; color: hsl(var(--color-text));
font-size: 0.8rem; font-size: 0.8rem;
} }
.admin-input:focus { .admin-input:focus {
outline: none; outline: none;
border-color: hsl(12 76% 48% / 0.5); border-color: hsl(var(--color-primary) / 0.5);
} }
.add-btn { .add-btn {
@@ -265,9 +405,9 @@ async function removeChapter(slug: string) {
gap: 0.5rem; gap: 0.5rem;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border-radius: 0.5rem; border-radius: 0.5rem;
border: 1px solid hsl(20 8% 25%); border: 1px solid hsl(var(--color-surface-light));
background: none; background: none;
color: hsl(20 8% 55%); color: hsl(var(--color-text-muted));
font-size: 0.85rem; font-size: 0.85rem;
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: all 0.15s;
@@ -275,8 +415,8 @@ async function removeChapter(slug: string) {
} }
.add-btn:hover:not(:disabled) { .add-btn:hover:not(:disabled) {
border-color: hsl(12 76% 48% / 0.5); border-color: hsl(var(--color-primary) / 0.5);
color: hsl(12 76% 68%); color: hsl(var(--color-primary));
} }
.add-btn:disabled { .add-btn:disabled {
+12 -6
View File
@@ -9,8 +9,8 @@
<p class="text-sm text-white/50">Identité, navigation, footer</p> <p class="text-sm text-white/50">Identité, navigation, footer</p>
</NuxtLink> </NuxtLink>
<NuxtLink to="/admin/pages/home" class="dash-card"> <NuxtLink to="/admin/pages" class="dash-card">
<div class="i-lucide-home h-8 w-8 text-primary mb-2" /> <div class="i-lucide-file-text h-8 w-8 text-primary mb-2" />
<h2 class="text-lg font-semibold text-white">Pages</h2> <h2 class="text-lg font-semibold text-white">Pages</h2>
<p class="text-sm text-white/50">Contenus des pages publiques</p> <p class="text-sm text-white/50">Contenus des pages publiques</p>
</NuxtLink> </NuxtLink>
@@ -27,6 +27,12 @@
<p class="text-sm text-white/50">Métadonnées des pistes</p> <p class="text-sm text-white/50">Métadonnées des pistes</p>
</NuxtLink> </NuxtLink>
<NuxtLink to="/admin/messages" class="dash-card">
<div class="i-lucide-message-square h-8 w-8 text-accent mb-2" />
<h2 class="text-lg font-semibold text-white">Messages</h2>
<p class="text-sm text-white/50">Modération des messages visiteurs</p>
</NuxtLink>
<NuxtLink to="/admin/media" class="dash-card"> <NuxtLink to="/admin/media" class="dash-card">
<div class="i-lucide-image h-8 w-8 text-accent mb-2" /> <div class="i-lucide-image h-8 w-8 text-accent mb-2" />
<h2 class="text-lg font-semibold text-white">Médias</h2> <h2 class="text-lg font-semibold text-white">Médias</h2>
@@ -47,14 +53,14 @@ definePageMeta({
.dash-card { .dash-card {
padding: 1.5rem; padding: 1.5rem;
border-radius: 0.75rem; border-radius: 0.75rem;
border: 1px solid hsl(20 8% 14%); border: 1px solid hsl(var(--color-surface-light));
background: hsl(20 8% 6%); background: hsl(var(--color-bg));
text-decoration: none; text-decoration: none;
transition: all 0.2s; transition: all 0.2s;
} }
.dash-card:hover { .dash-card:hover {
border-color: hsl(12 76% 48% / 0.3); border-color: hsl(var(--color-primary) / 0.3);
background: hsl(20 8% 8%); background: hsl(var(--color-surface));
} }
</style> </style>
+13 -13
View File
@@ -72,16 +72,16 @@ async function login() {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: hsl(20 8% 3.5%); background: hsl(var(--color-bg));
} }
.login-form { .login-form {
width: 100%; width: 100%;
max-width: 24rem; max-width: 24rem;
padding: 2.5rem; padding: 2.5rem;
border: 1px solid hsl(20 8% 14%); border: 1px solid hsl(var(--color-surface-light));
border-radius: 1rem; border-radius: 1rem;
background: hsl(20 8% 6%); background: hsl(var(--color-bg));
} }
.login-error { .login-error {
@@ -97,7 +97,7 @@ async function login() {
display: block; display: block;
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 500; font-weight: 500;
color: hsl(20 8% 60%); color: hsl(var(--color-text-muted));
margin-bottom: 0.375rem; margin-bottom: 0.375rem;
} }
@@ -105,16 +105,16 @@ async function login() {
width: 100%; width: 100%;
padding: 0.625rem 0.75rem; padding: 0.625rem 0.75rem;
border-radius: 0.5rem; border-radius: 0.5rem;
border: 1px solid hsl(20 8% 18%); border: 1px solid hsl(var(--color-surface-light));
background: hsl(20 8% 4%); background: hsl(var(--color-bg));
color: white; color: hsl(var(--color-text));
font-size: 0.9rem; font-size: 0.9rem;
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
} }
.login-input:focus { .login-input:focus {
outline: none; outline: none;
border-color: hsl(12 76% 48% / 0.5); border-color: hsl(var(--color-primary) / 0.5);
} }
.login-btn { .login-btn {
@@ -126,7 +126,7 @@ async function login() {
padding: 0.625rem; padding: 0.625rem;
border-radius: 0.5rem; border-radius: 0.5rem;
border: none; border: none;
background: hsl(12 76% 48%); background: hsl(var(--color-primary));
color: white; color: white;
font-weight: 600; font-weight: 600;
font-size: 0.9rem; font-size: 0.9rem;
@@ -135,7 +135,7 @@ async function login() {
} }
.login-btn:hover:not(:disabled) { .login-btn:hover:not(:disabled) {
background: hsl(12 76% 42%); background: hsl(var(--color-primary));
} }
.login-btn:disabled { .login-btn:disabled {
@@ -147,12 +147,12 @@ async function login() {
margin-top: 1rem; margin-top: 1rem;
text-align: center; text-align: center;
font-size: 0.75rem; font-size: 0.75rem;
color: hsl(20 8% 40%); color: hsl(var(--color-text-muted));
} }
.dev-hint code { .dev-hint code {
color: hsl(12 76% 60%); color: hsl(var(--color-primary));
background: hsl(20 8% 10%); background: hsl(var(--color-surface));
padding: 0.125rem 0.375rem; padding: 0.125rem 0.375rem;
border-radius: 0.25rem; border-radius: 0.25rem;
} }
+161 -33
View File
@@ -5,7 +5,7 @@
<span class="text-sm text-white/40">{{ messages?.length || 0 }} message(s)</span> <span class="text-sm text-white/40">{{ messages?.length || 0 }} message(s)</span>
</div> </div>
<div v-if="messages?.length" class="space-y-3"> <div v-if="messages?.length" class="space-y-4">
<div <div
v-for="msg in messages" v-for="msg in messages"
:key="msg.id" :key="msg.id"
@@ -17,6 +17,9 @@
<div class="flex items-center gap-2 min-w-0"> <div class="flex items-center gap-2 min-w-0">
<span class="font-semibold text-white text-sm truncate">{{ msg.author }}</span> <span class="font-semibold text-white text-sm truncate">{{ msg.author }}</span>
<span v-if="msg.email" class="text-white/30 text-xs truncate">{{ msg.email }}</span> <span v-if="msg.email" class="text-white/30 text-xs truncate">{{ msg.email }}</span>
<span class="type-badge" :class="`type-badge--${msg.type || 'reaction'}`">
{{ typeLabel(msg.type) }}
</span>
</div> </div>
<div class="flex items-center gap-2 shrink-0"> <div class="flex items-center gap-2 shrink-0">
<span class="text-xs text-white/30">{{ formatDate(msg.createdAt) }}</span> <span class="text-xs text-white/30">{{ formatDate(msg.createdAt) }}</span>
@@ -29,23 +32,54 @@
</div> </div>
</div> </div>
<!-- Texte éditable --> <!-- Texte du message -->
<div v-if="editing === msg.id" class="mb-3"> <div v-if="editing === msg.id" class="mb-3 space-y-2">
<input <input v-model="editForm.author" class="admin-input w-full" placeholder="Auteur" />
v-model="editForm.author" <select v-model="editForm.type" class="admin-input w-full">
class="admin-input mb-2 w-full" <option value="question">Question</option>
placeholder="Auteur" <option value="suggestion">Suggestion</option>
/> <option value="retour">Retour d'expérience</option>
<textarea </select>
v-model="editForm.text" <textarea v-model="editForm.text" class="admin-input w-full" rows="3" />
class="admin-input w-full" </div>
rows="3" <p v-else class="msg-text text-sm leading-relaxed mb-3">{{ msg.text }}</p>
/>
<!-- Réponse existante -->
<div v-if="msg.reply && replyEditing !== msg.id" class="reply-block mb-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-semibold text-primary/80">Réponse publiée</span>
<span class="text-xs text-white/30">{{ formatDate(msg.reply.publishedAt) }}</span>
</div>
<p class="text-white/60 text-sm leading-relaxed">{{ msg.reply.text }}</p>
<button class="action-btn mt-2 text-xs" @click="startReply(msg)">
<div class="i-lucide-pencil h-3 w-3" />
Modifier la réponse
</button>
</div>
<!-- Formulaire réponse -->
<div v-if="replyEditing === msg.id" class="reply-form mb-3">
<textarea
v-model="replyText"
class="admin-input w-full text-sm"
rows="3"
placeholder="Votre réponse..."
/>
<div class="flex gap-2 mt-2">
<button class="action-btn action-btn--save" @click="saveReply(msg)">
<div class="i-lucide-check h-3.5 w-3.5" />
Publier la réponse
</button>
<button v-if="msg.reply" class="action-btn action-btn--danger" @click="removeReply(msg)">
<div class="i-lucide-x h-3.5 w-3.5" />
Supprimer la réponse
</button>
<button class="action-btn" @click="replyEditing = null">Annuler</button>
</div>
</div> </div>
<p v-else class="text-white/70 text-sm leading-relaxed mb-3">{{ msg.text }}</p>
<!-- Actions --> <!-- Actions -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2 flex-wrap">
<button class="action-btn" @click="togglePublished(msg)"> <button class="action-btn" @click="togglePublished(msg)">
<div :class="msg.published ? 'i-lucide-eye-off' : 'i-lucide-eye'" class="h-3.5 w-3.5" /> <div :class="msg.published ? 'i-lucide-eye-off' : 'i-lucide-eye'" class="h-3.5 w-3.5" />
{{ msg.published ? 'Dépublier' : 'Publier' }} {{ msg.published ? 'Dépublier' : 'Publier' }}
@@ -56,15 +90,18 @@
<div class="i-lucide-check h-3.5 w-3.5" /> <div class="i-lucide-check h-3.5 w-3.5" />
Valider Valider
</button> </button>
<button class="action-btn" @click="editing = null"> <button class="action-btn" @click="editing = null">Annuler</button>
Annuler
</button>
</template> </template>
<button v-else class="action-btn" @click="startEdit(msg)"> <button v-else class="action-btn" @click="startEdit(msg)">
<div class="i-lucide-pencil h-3.5 w-3.5" /> <div class="i-lucide-pencil h-3.5 w-3.5" />
Modifier Modifier
</button> </button>
<button v-if="replyEditing !== msg.id && !msg.reply" class="action-btn action-btn--reply" @click="startReply(msg)">
<div class="i-lucide-reply h-3.5 w-3.5" />
Répondre
</button>
<button class="action-btn action-btn--danger ml-auto" @click="remove(msg)"> <button class="action-btn action-btn--danger ml-auto" @click="remove(msg)">
<div class="i-lucide-trash-2 h-3.5 w-3.5" /> <div class="i-lucide-trash-2 h-3.5 w-3.5" />
Supprimer Supprimer
@@ -86,18 +123,33 @@ definePageMeta({
const { data: messages, refresh } = await useFetch<any[]>('/api/admin/messages') const { data: messages, refresh } = await useFetch<any[]>('/api/admin/messages')
const editing = ref<number | null>(null) const editing = ref<number | null>(null)
const editForm = reactive({ author: '', text: '' }) const editForm = reactive({ author: '', text: '', type: 'question' })
const replyEditing = ref<number | null>(null)
const replyText = ref('')
const TYPE_LABELS: Record<string, string> = {
reaction: 'Réaction',
question: 'Question',
suggestion: 'Suggestion',
retour: 'Retour',
}
function typeLabel(type: string) {
return TYPE_LABELS[type] ?? type
}
function startEdit(msg: any) { function startEdit(msg: any) {
editing.value = msg.id editing.value = msg.id
editForm.author = msg.author editForm.author = msg.author
editForm.text = msg.text editForm.text = msg.text
editForm.type = TYPE_LABELS[msg.type] ? msg.type : 'question'
} }
async function saveEdit(msg: any) { async function saveEdit(msg: any) {
await $fetch(`/api/admin/messages/${msg.id}`, { await $fetch(`/api/admin/messages/${msg.id}`, {
method: 'PUT', method: 'PUT',
body: { author: editForm.author, text: editForm.text }, body: { author: editForm.author, text: editForm.text, type: editForm.type },
}) })
editing.value = null editing.value = null
await refresh() await refresh()
@@ -111,6 +163,29 @@ async function togglePublished(msg: any) {
await refresh() await refresh()
} }
function startReply(msg: any) {
replyEditing.value = msg.id
replyText.value = msg.reply?.text ?? ''
}
async function saveReply(msg: any) {
await $fetch(`/api/admin/messages/${msg.id}`, {
method: 'PUT',
body: { reply: replyText.value.trim() || null },
})
replyEditing.value = null
await refresh()
}
async function removeReply(msg: any) {
await $fetch(`/api/admin/messages/${msg.id}`, {
method: 'PUT',
body: { reply: null },
})
replyEditing.value = null
await refresh()
}
async function remove(msg: any) { async function remove(msg: any) {
if (!confirm(`Supprimer le message de "${msg.author}" ?`)) return if (!confirm(`Supprimer le message de "${msg.author}" ?`)) return
await $fetch(`/api/admin/messages/${msg.id}`, { method: 'DELETE' }) await $fetch(`/api/admin/messages/${msg.id}`, { method: 'DELETE' })
@@ -130,14 +205,14 @@ function formatDate(iso: string) {
<style scoped> <style scoped>
.msg-row { .msg-row {
background: hsl(20 8% 6%); background: hsl(var(--color-bg));
border: 1px solid hsl(20 8% 14%); border: 1px solid hsl(var(--color-surface-light));
border-radius: 0.75rem; border-radius: 0.75rem;
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
} }
.msg-row--draft { .msg-row--draft {
border-left: 3px solid hsl(36 80% 52%); border-left: 3px solid hsl(var(--color-accent));
} }
.status-badge { .status-badge {
@@ -155,22 +230,61 @@ function formatDate(iso: string) {
} }
.status-badge--draft { .status-badge--draft {
background: hsl(36 80% 52% / 0.15); background: hsl(var(--color-accent) / 0.15);
color: hsl(36 80% 66%); color: hsl(var(--color-accent));
}
.type-badge {
font-size: 0.6rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.1rem 0.45rem;
border-radius: 9999px;
background: hsl(var(--color-primary) / 0.12);
color: hsl(var(--color-primary) / 0.7);
}
.type-badge--question {
background: hsl(220 70% 50% / 0.12);
color: hsl(220 70% 65%);
}
.type-badge--suggestion {
background: hsl(280 60% 55% / 0.12);
color: hsl(280 60% 70%);
}
.type-badge--retour {
background: hsl(35 70% 50% / 0.12);
color: hsl(35 70% 65%);
}
.reply-block {
background: hsl(var(--color-primary) / 0.06);
border-left: 2px solid hsl(var(--color-primary) / 0.3);
border-radius: 0 0.5rem 0.5rem 0;
padding: 0.75rem 1rem;
}
.reply-form {
background: hsl(var(--color-surface) / 0.5);
border-radius: 0.5rem;
padding: 0.75rem;
} }
.admin-input { .admin-input {
padding: 0.375rem 0.5rem; padding: 0.375rem 0.5rem;
border-radius: 0.375rem; border-radius: 0.375rem;
border: 1px solid hsl(20 8% 18%); border: 1px solid hsl(var(--color-surface-light));
background: hsl(20 8% 6%); background: hsl(var(--color-bg));
color: white; color: hsl(var(--color-text));
font-size: 0.8rem; font-size: 0.8rem;
} }
.admin-input:focus { .admin-input:focus {
outline: none; outline: none;
border-color: hsl(12 76% 48% / 0.5); border-color: hsl(var(--color-primary) / 0.5);
} }
.action-btn { .action-btn {
@@ -180,16 +294,16 @@ function formatDate(iso: string) {
padding: 0.3rem 0.6rem; padding: 0.3rem 0.6rem;
border-radius: 0.375rem; border-radius: 0.375rem;
font-size: 0.75rem; font-size: 0.75rem;
color: hsl(20 8% 60%); color: hsl(var(--color-text-muted));
background: none; background: none;
border: 1px solid hsl(20 8% 16%); border: 1px solid hsl(var(--color-surface-light));
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
} }
.action-btn:hover { .action-btn:hover {
background: hsl(20 8% 10%); background: hsl(var(--color-surface));
color: white; color: hsl(var(--color-text));
} }
.action-btn--save { .action-btn--save {
@@ -201,6 +315,20 @@ function formatDate(iso: string) {
background: hsl(142 70% 40% / 0.1); background: hsl(142 70% 40% / 0.1);
} }
.action-btn--reply {
border-color: hsl(var(--color-primary) / 0.3);
color: hsl(var(--color-primary) / 0.8);
}
.action-btn--reply:hover {
background: hsl(var(--color-primary) / 0.08);
}
.msg-text {
color: hsl(var(--color-text) / 0.72);
white-space: pre-line;
}
.action-btn--danger:hover { .action-btn--danger:hover {
background: hsl(0 70% 40% / 0.1); background: hsl(0 70% 40% / 0.1);
border-color: hsl(0 70% 40% / 0.3); border-color: hsl(0 70% 40% / 0.3);
@@ -2,11 +2,11 @@
<div> <div>
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<div> <div>
<NuxtLink to="/admin" class="text-sm text-white/40 hover:text-white/60 transition-colors"> <NuxtLink to="/admin/pages" class="text-sm text-white/40 hover:text-white/60 transition-colors">
← Dashboard ← Pages
</NuxtLink> </NuxtLink>
<h1 class="font-display text-2xl font-bold text-white mt-1"> <h1 class="font-display text-2xl font-bold text-white mt-1">
Page : {{ pageName }} Page : {{ pagePath }}
</h1> </h1>
</div> </div>
<AdminSaveButton :saving="saving" :saved="saved" @save="save" /> <AdminSaveButton :saving="saving" :saved="saved" @save="save" />
@@ -40,9 +40,12 @@ definePageMeta({
}) })
const route = useRoute() const route = useRoute()
const pageName = computed(() => route.params.name as string) const pagePath = computed(() => {
const p = route.params.path
return Array.isArray(p) ? p.join('/') : p
})
const { data, error } = await useFetch(() => `/api/content/pages/${pageName.value}`) const { data, error } = await useFetch(() => `/api/content/pages/${pagePath.value}`)
const yamlContent = ref('') const yamlContent = ref('')
@@ -60,7 +63,7 @@ async function save() {
saved.value = false saved.value = false
try { try {
const parsed = yaml.parse(yamlContent.value) const parsed = yaml.parse(yamlContent.value)
await $fetch(`/api/admin/content/pages/${pageName.value}`, { await $fetch(`/api/admin/content/pages/${pagePath.value}`, {
method: 'PUT', method: 'PUT',
body: parsed, body: parsed,
}) })
@@ -80,10 +83,10 @@ async function save() {
.yaml-textarea { .yaml-textarea {
width: 100%; width: 100%;
padding: 1rem; padding: 1rem;
border: 1px solid hsl(20 8% 18%); border: 1px solid hsl(var(--color-surface-light));
border-radius: 0.5rem; border-radius: 0.5rem;
background: hsl(20 8% 4%); background: hsl(var(--color-bg));
color: hsl(36 80% 76%); color: hsl(var(--color-accent));
font-family: var(--font-mono, monospace); font-family: var(--font-mono, monospace);
font-size: 0.85rem; font-size: 0.85rem;
line-height: 1.7; line-height: 1.7;
@@ -93,6 +96,6 @@ async function save() {
.yaml-textarea:focus { .yaml-textarea:focus {
outline: none; outline: none;
border-color: hsl(12 76% 48% / 0.5); border-color: hsl(var(--color-primary) / 0.5);
} }
</style> </style>
+97
View File
@@ -0,0 +1,97 @@
<template>
<div>
<div class="flex items-center justify-between mb-6">
<div>
<NuxtLink to="/admin" class="text-sm text-white/40 hover:text-white/60 transition-colors">
← Dashboard
</NuxtLink>
<h1 class="font-display text-2xl font-bold text-white mt-1">Pages</h1>
</div>
</div>
<div v-if="pages" class="flex flex-col gap-6">
<!-- Root pages -->
<div v-if="rootPages.length">
<h2 class="font-display text-lg font-semibold text-white/80 mb-3">Pages principales</h2>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<NuxtLink
v-for="page in rootPages"
:key="page.path"
:to="`/admin/pages/${page.path}`"
class="page-card"
>
<div class="i-lucide-file-text h-5 w-5 text-primary mb-1" />
<span class="font-mono text-sm text-white">{{ page.label }}</span>
</NuxtLink>
</div>
</div>
<!-- Section pages -->
<div v-for="[section, sectionPages] in sections" :key="section">
<h2 class="font-display text-lg font-semibold text-white/80 mb-3">
<span class="i-lucide-folder h-4 w-4 inline-block mr-1 text-accent" />
{{ section }}
</h2>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<NuxtLink
v-for="page in sectionPages"
:key="page.path"
:to="`/admin/pages/${page.path}`"
class="page-card"
>
<div class="i-lucide-file-text h-5 w-5 text-primary/60 mb-1" />
<span class="font-mono text-sm text-white">{{ page.label }}</span>
</NuxtLink>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'admin',
middleware: 'admin',
})
interface PageEntry {
path: string
label: string
section?: string
}
const { data: pages } = await useFetch<PageEntry[]>('/api/admin/content/pages')
const rootPages = computed(() =>
(pages.value ?? []).filter(p => !p.section),
)
const sections = computed(() => {
const map = new Map<string, PageEntry[]>()
for (const p of pages.value ?? []) {
if (p.section) {
if (!map.has(p.section)) map.set(p.section, [])
map.get(p.section)!.push(p)
}
}
return Array.from(map.entries())
})
</script>
<style scoped>
.page-card {
display: flex;
flex-direction: column;
padding: 1rem;
border-radius: 0.5rem;
border: 1px solid hsl(var(--color-surface-light));
background: hsl(var(--color-bg));
text-decoration: none;
transition: all 0.2s;
}
.page-card:hover {
border-color: hsl(var(--color-primary) / 0.3);
background: hsl(var(--color-surface));
}
</style>
+4 -4
View File
@@ -103,14 +103,14 @@ async function save() {
.admin-input { .admin-input {
padding: 0.375rem 0.5rem; padding: 0.375rem 0.5rem;
border-radius: 0.375rem; border-radius: 0.375rem;
border: 1px solid hsl(20 8% 18%); border: 1px solid hsl(var(--color-surface-light));
background: hsl(20 8% 6%); background: hsl(var(--color-bg));
color: white; color: hsl(var(--color-text));
font-size: 0.8rem; font-size: 0.8rem;
} }
.admin-input:focus { .admin-input:focus {
outline: none; outline: none;
border-color: hsl(12 76% 48% / 0.5); border-color: hsl(var(--color-primary) / 0.5);
} }
</style> </style>
+12 -12
View File
@@ -163,7 +163,7 @@ async function save() {
align-items: flex-start; align-items: flex-start;
gap: 0.75rem; gap: 0.75rem;
padding: 0.75rem 0.5rem; padding: 0.75rem 0.5rem;
border-bottom: 1px solid hsl(20 8% 10%); border-bottom: 1px solid hsl(var(--color-surface));
transition: background 0.15s; transition: background 0.15s;
} }
@@ -172,13 +172,13 @@ async function save() {
} }
.song-row--over { .song-row--over {
border-top: 2px solid hsl(12 76% 48%); border-top: 2px solid hsl(var(--color-primary));
} }
.drag-handle { .drag-handle {
cursor: grab; cursor: grab;
padding: 0.25rem; padding: 0.25rem;
color: hsl(20 8% 35%); color: hsl(var(--color-text-muted));
flex-shrink: 0; flex-shrink: 0;
margin-top: 0.25rem; margin-top: 0.25rem;
} }
@@ -190,7 +190,7 @@ async function save() {
.song-num { .song-num {
font-family: var(--font-mono, monospace); font-family: var(--font-mono, monospace);
font-size: 0.8rem; font-size: 0.8rem;
color: hsl(20 8% 40%); color: hsl(var(--color-text-muted));
width: 1.25rem; width: 1.25rem;
text-align: right; text-align: right;
flex-shrink: 0; flex-shrink: 0;
@@ -200,16 +200,16 @@ async function save() {
.admin-input { .admin-input {
padding: 0.375rem 0.5rem; padding: 0.375rem 0.5rem;
border-radius: 0.375rem; border-radius: 0.375rem;
border: 1px solid hsl(20 8% 18%); border: 1px solid hsl(var(--color-surface-light));
background: hsl(20 8% 6%); background: hsl(var(--color-bg));
color: white; color: hsl(var(--color-text));
font-size: 0.8rem; font-size: 0.8rem;
width: 100%; width: 100%;
} }
.admin-input:focus { .admin-input:focus {
outline: none; outline: none;
border-color: hsl(12 76% 48% / 0.5); border-color: hsl(var(--color-primary) / 0.5);
} }
.lyrics-textarea { .lyrics-textarea {
@@ -240,16 +240,16 @@ async function save() {
margin-top: 1rem; margin-top: 1rem;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border-radius: 0.5rem; border-radius: 0.5rem;
border: 1px dashed hsl(20 8% 25%); border: 1px dashed hsl(var(--color-surface-light));
background: none; background: none;
color: hsl(20 8% 55%); color: hsl(var(--color-text-muted));
font-size: 0.85rem; font-size: 0.85rem;
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: all 0.15s;
} }
.add-btn:hover { .add-btn:hover {
border-color: hsl(12 76% 48% / 0.5); border-color: hsl(var(--color-primary) / 0.5);
color: hsl(12 76% 68%); color: hsl(var(--color-primary));
} }
</style> </style>
-168
View File
@@ -1,168 +0,0 @@
<template>
<div class="relative overflow-hidden section-padding">
<!-- Shadok jardinier: character with watering can and plant -->
<svg class="shadok-jardinier" viewBox="0 0 240 300" fill="none" aria-hidden="true">
<!-- Body -->
<ellipse cx="110" cy="160" rx="40" ry="48" fill="currentColor" opacity="0.85"/>
<!-- Head -->
<circle cx="110" cy="96" r="25" fill="currentColor" opacity="0.8"/>
<!-- Straw hat -->
<ellipse cx="110" cy="78" rx="35" ry="8" fill="currentColor" opacity="0.4"/>
<path d="M85 78 Q110 60 135 78" fill="currentColor" opacity="0.35"/>
<!-- Eyes (focused, looking down at plant) -->
<circle cx="102" cy="94" r="4" fill="currentColor" opacity="0.2"/>
<circle cx="120" cy="94" r="4" fill="currentColor" opacity="0.2"/>
<circle cx="103" cy="95" r="1.8" fill="currentColor" opacity="0.5"/>
<circle cx="121" cy="95" r="1.8" fill="currentColor" opacity="0.5"/>
<!-- Smile -->
<path d="M103 106 Q110 111 118 106" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.3"/>
<!-- Arm holding watering can -->
<line x1="70" y1="150" x2="40" y2="170" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<!-- Watering can -->
<rect x="20" y="165" width="30" height="20" rx="3" fill="currentColor" opacity="0.4"/>
<line x1="20" y1="168" x2="10" y2="160" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.4"/>
<!-- Water drops -->
<circle cx="12" cy="165" r="1.5" fill="currentColor" opacity="0.25"/>
<circle cx="8" cy="170" r="1.5" fill="currentColor" opacity="0.2"/>
<circle cx="15" cy="172" r="1.5" fill="currentColor" opacity="0.2"/>
<!-- Other arm -->
<line x1="150" y1="150" x2="170" y2="180" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<!-- Legs -->
<line x1="95" y1="205" x2="85" y2="260" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<line x1="125" y1="205" x2="135" y2="260" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<!-- Plant -->
<line x1="180" y1="220" x2="180" y2="180" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
<path d="M180 195 Q195 185 190 175" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.35"/>
<path d="M180 205 Q165 195 168 185" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.35"/>
<path d="M180 185 Q192 172 188 165" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.3"/>
<!-- Pot -->
<path d="M170 220 L175 240 L185 240 L190 220 Z" fill="currentColor" opacity="0.35"/>
</svg>
<!-- Shadok bâtisseur: character with trowel building a wall -->
<svg class="shadok-batisseur" viewBox="0 0 260 300" fill="none" aria-hidden="true">
<!-- Body -->
<ellipse cx="130" cy="150" rx="40" ry="48" fill="currentColor" opacity="0.85"/>
<!-- Head -->
<circle cx="130" cy="86" r="25" fill="currentColor" opacity="0.8"/>
<!-- Hard hat -->
<ellipse cx="130" cy="68" rx="28" ry="6" fill="currentColor" opacity="0.4"/>
<rect x="108" y="60" width="44" height="10" rx="3" fill="currentColor" opacity="0.35"/>
<!-- Eyes (determined) -->
<circle cx="122" cy="84" r="4" fill="currentColor" opacity="0.2"/>
<circle cx="140" cy="84" r="4" fill="currentColor" opacity="0.2"/>
<circle cx="123" cy="83" r="1.8" fill="currentColor" opacity="0.5"/>
<circle cx="141" cy="83" r="1.8" fill="currentColor" opacity="0.5"/>
<!-- Grin -->
<path d="M123 96 Q130 101 138 96" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.3"/>
<!-- Arm with trowel -->
<line x1="170" y1="140" x2="210" y2="120" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<!-- Trowel -->
<polygon points="210,115 230,110 225,120 210,122" fill="currentColor" opacity="0.45"/>
<line x1="210" y1="118" x2="200" y2="125" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.4"/>
<!-- Other arm -->
<line x1="90" y1="145" x2="65" y2="170" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<!-- Legs -->
<line x1="115" y1="195" x2="105" y2="255" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<line x1="145" y1="195" x2="155" y2="255" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<!-- Wall (bricks) -->
<rect x="40" y="200" width="50" height="16" rx="1" fill="currentColor" opacity="0.3"/>
<rect x="45" y="183" width="40" height="16" rx="1" fill="currentColor" opacity="0.28"/>
<rect x="50" y="166" width="30" height="16" rx="1" fill="currentColor" opacity="0.25"/>
<!-- Brick lines -->
<line x1="65" y1="200" x2="65" y2="216" stroke="currentColor" stroke-width="1" opacity="0.15"/>
<line x1="55" y1="183" x2="55" y2="199" stroke="currentColor" stroke-width="1" opacity="0.15"/>
</svg>
<div class="container-content">
<header class="mb-12 text-center">
<p class="mb-2 font-mono text-sm tracking-widest text-primary uppercase">{{ content?.kicker }}</p>
<h1 class="page-title font-display font-bold tracking-tight text-white">
{{ content?.title }}
</h1>
<p class="mt-4 mx-auto max-w-2xl text-white/60">
{{ content?.description }}
</p>
</header>
<div class="mx-auto max-w-3xl flex flex-col gap-6">
<div
v-for="(extract, i) in content?.extracts"
:key="i"
class="card-surface"
>
<p class="mb-2 font-mono text-xs tracking-widest text-accent uppercase">
{{ extract.chapter }}
</p>
<blockquote class="border-l-2 border-primary/30 pl-4 text-white/70 italic leading-relaxed whitespace-pre-line">
{{ extract.text }}
</blockquote>
<div class="mt-4">
<NuxtLink
:to="`/modele-eco/${extract.chapterSlug}`"
class="inline-flex items-center gap-1 text-sm text-primary hover:text-primary/80 transition-colors"
>
Lire le chapitre
<div class="i-lucide-arrow-right h-3.5 w-3.5" />
</NuxtLink>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'default',
})
const { data: content } = await usePageContent('autonomie')
useHead({
title: content.value?.meta?.title ?? 'Autonomie',
})
</script>
<style scoped>
.page-title {
font-size: clamp(2rem, 5vw, 2.75rem);
}
.shadok-jardinier {
position: absolute;
left: 2%;
top: 5%;
width: clamp(100px, 14vw, 200px);
opacity: 0.28;
pointer-events: none;
color: hsl(var(--color-primary));
animation: shadok-float-jardinier 10s ease-in-out infinite;
}
.shadok-batisseur {
position: absolute;
right: 2%;
bottom: 5%;
width: clamp(110px, 15vw, 210px);
opacity: 0.3;
pointer-events: none;
color: hsl(var(--color-accent));
animation: shadok-float-batisseur 11s ease-in-out infinite;
}
@keyframes shadok-float-jardinier {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
@keyframes shadok-float-batisseur {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-8px) rotate(1deg); }
}
@media (max-width: 768px) {
.shadok-jardinier { display: none; }
.shadok-batisseur { display: none; }
}
</style>
+400
View File
@@ -0,0 +1,400 @@
<template>
<div class="axis-scope axis-scope--cit section-padding">
<div class="container-content">
<!-- Header -->
<div class="mx-auto max-w-2xl text-center mb-8">
<div class="section-icon mx-auto mb-6">
<div :class="`i-lucide-${content?.icon ?? 'landmark'}`" class="h-12 w-12" />
</div>
<p class="mb-2 font-mono text-sm tracking-widest text-primary uppercase text-center">
{{ content?.kicker }}
</p>
<h1 class="font-display text-3xl font-bold mb-4" style="color: hsl(var(--color-text))">
{{ content?.title ?? slug }}
</h1>
<p class="text-lg leading-relaxed" style="color: hsl(var(--color-text-muted))">
{{ content?.description }}
</p>
</div>
<!-- Navigation prev / index / next -->
<nav class="ctx-nav mx-auto max-w-2xl mb-10">
<NuxtLink v-if="prevItem?.to" :to="prevItem.to" class="ctx-nav-btn ctx-nav-prev">
<div class="i-lucide-arrow-left h-4 w-4 shrink-0" />
<span>{{ prevItem.label }}</span>
</NuxtLink>
<div v-else class="ctx-nav-spacer" />
<NuxtLink to="/citoyenne" class="ctx-nav-btn ctx-nav-index">
<div class="i-lucide-layout-grid h-4 w-4" />
<span>Citoyenne</span>
</NuxtLink>
<NuxtLink v-if="nextItem?.to" :to="nextItem.to" class="ctx-nav-btn ctx-nav-next">
<span>{{ nextItem.label }}</span>
<div class="i-lucide-arrow-right h-4 w-4 shrink-0" />
</NuxtLink>
<div v-else class="ctx-nav-spacer" />
</nav>
<!-- Zone sections — relative pour ancrer le sidebar -->
<div class="sections-area">
<!-- Sidebar sommaire -->
<aside v-if="sommaire.length > 1" class="page-sidebar">
<nav class="sommaire-sidebar">
<p class="sommaire-sidebar-title">Sur cette page</p>
<ol class="sommaire-sidebar-list">
<li v-for="(entry, ei) in sommaire" :key="ei">
<a :href="`#${entry.id}`" class="sommaire-sidebar-link">
<span class="sommaire-n">{{ ei + 1 }}</span>
<span>{{ entry.title }}</span>
</a>
</li>
</ol>
</nav>
</aside>
<!-- Features grid -->
<div
v-if="content?.features"
id="section-features"
class="relative grid gap-4 sm:grid-cols-2 mb-12 max-w-2xl mx-auto section-anchor"
>
<UiOiseauPose v-for="o in oiseaux.fonctions ?? []" :key="`${o.nom}-${o.pose}`" v-bind="o" />
<div v-for="feature in content.features" :key="feature.title" class="feature-card">
<div class="feature-icon">
<div :class="`i-lucide-${feature.icon} h-5 w-5`" />
</div>
<h3 class="font-display font-semibold mb-1" style="color: hsl(var(--color-text))">{{ feature.title }}</h3>
<p class="text-sm leading-relaxed" style="color: hsl(var(--color-text-muted))">{{ feature.text }}</p>
</div>
</div>
<!-- Project card -->
<div
v-if="content?.project"
id="section-project"
class="project-card mb-8 max-w-2xl mx-auto section-anchor"
>
<UiOiseauPose v-for="o in oiseaux.projet ?? []" :key="`${o.nom}-${o.pose}`" v-bind="o" />
<div class="project-icon">
<div class="i-lucide-rocket h-5 w-5" />
</div>
<h2 class="font-display text-xl font-semibold mb-2" style="color: hsl(var(--color-text))">
{{ content.project.name }}
</h2>
<p style="color: hsl(var(--color-text-muted))" class="leading-relaxed">
{{ content.project.text }}
</p>
<UiStatusBadge class="mt-3" :status="content?.status" :gestation="content?.gestation" />
</div>
<!-- Extended content -->
<div
v-if="content?.content"
id="section-content"
class="prose-block relative mb-10 max-w-2xl mx-auto section-anchor"
>
<UiOiseauPose v-for="o in oiseaux.texte ?? []" :key="`${o.nom}-${o.pose}`" v-bind="o" />
<p class="leading-relaxed whitespace-pre-line" style="color: hsl(var(--color-text-muted))">
{{ content.content }}
</p>
</div>
<!-- Actions -->
<div class="text-center flex flex-col items-center gap-3 sm:flex-row sm:justify-center mb-4">
<UiBaseButton v-if="slug === 'decision'" :href="decisionUrl" target="_blank">
<div class="i-lucide-external-link mr-2 h-4 w-4" />
Ouvrir libreDecision
</UiBaseButton>
<UiBaseButton v-if="slug === 'tarifs-eau'" :href="sejeteral0Url" target="_blank">
<div class="i-lucide-external-link mr-2 h-4 w-4" />
Lancer SejeteralO
</UiBaseButton>
</div>
</div><!-- /sections-area -->
</div>
</div>
</template>
<script setup lang="ts">
import type { OiseauPlace } from '~/utils/oiseauPose'
const route = useRoute()
const slug = route.params.slug as string
// Birds of each page: by the features grid, the project card and the text block
interface OiseauxPage { fonctions?: OiseauPlace[], projet?: OiseauPlace[], texte?: OiseauPlace[] }
const OISEAUX_PAGE: Record<string, OiseauxPage> = {
'decision': {
fonctions: [
{ nom: 'mime', pose: 'marge-g', ecart: '48px', w: 104 },
// on the line under the first row of cards (16 px gap between the rows)
{ nom: 'avocate', pose: 'marge-d', ecart: '44px', bottom: 'calc(50% + 8px)', w: 96, retourne: true },
// above the grid's left corner the page is empty (the links row starts at its middle)
{ nom: 'tisserand', pose: 'perche', left: '24px', w: 84 },
],
},
'tarifs-eau': {
// the actor in the right margin, feet on the card's top edge: perched on the card, it stood
// 11 px under the introduction (rule f, DA 03/11 after 17h35); the summary starts lower
projet: [{ nom: 'comedien', pose: 'marge-d', ecart: '24px', bottom: '100%', w: 100, retourne: true }],
texte: [{ nom: 'potier', pose: 'marge-g', ecart: '52px', w: 112 }],
},
}
const oiseaux = computed<OiseauxPage>(() => OISEAUX_PAGE[slug] ?? {})
const [{ data: content }, { data: homeData }] = await Promise.all([
usePageContent(`citoyenne/${slug}`),
usePageContent('home'),
])
const appConfig = useAppConfig()
const decisionUrl = (appConfig.libredecision as { url: string })?.url ?? '#'
const sejeteral0Url = (appConfig.sejeteral0 as { url: string })?.url ?? '#'
// Sommaire
const sommaire = computed(() => {
const entries: { title: string; id: string }[] = []
if ((content.value?.features as unknown[])?.length) entries.push({ title: 'Fonctionnalités', id: 'section-features' })
if (content.value?.project) entries.push({ title: (content.value.project as { name: string }).name, id: 'section-project' })
if (content.value?.content) entries.push({ title: 'En savoir plus', id: 'section-content' })
return entries
})
// Prev / next dans la section citoyenne
interface AxisItem { label: string; to?: string; icon?: string }
const politiqueItems = computed<AxisItem[]>(
() => (homeData.value as Record<string, unknown> | null)?.axes?.politique?.items as AxisItem[] ?? [],
)
const currentPath = `/citoyenne/${slug}`
const currentIdx = computed(() => politiqueItems.value.findIndex(i => i.to === currentPath))
const prevItem = computed(() => currentIdx.value > 0 ? politiqueItems.value[currentIdx.value - 1] : null)
const nextItem = computed(() => currentIdx.value < politiqueItems.value.length - 1 ? politiqueItems.value[currentIdx.value + 1] : null)
useSeoPage({
title: content.value?.meta?.title ?? slug,
description: content.value?.description as string ?? undefined,
})
</script>
<style scoped>
.section-icon {
display: flex;
align-items: center;
justify-content: center;
width: 5rem;
height: 5rem;
border-radius: 1rem;
background: hsl(var(--color-primary) / 0.1);
border: 1px solid hsl(var(--color-primary) / 0.2);
color: hsl(var(--color-primary));
}
.feature-card {
padding: 1.25rem;
border-radius: 0.75rem;
border: 1px solid hsl(var(--color-text) / 0.08);
background: hsl(var(--color-surface));
}
.feature-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.5rem;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
margin-bottom: 0.75rem;
}
.project-card {
position: relative;
padding: 1.5rem;
border-radius: 0.75rem;
border: 1px solid hsl(var(--color-primary) / 0.15);
background: hsl(var(--color-surface));
}
.project-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 0.5rem;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
margin-bottom: 1rem;
}
.gestation-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
background: hsl(var(--color-accent) / 0.12);
color: hsl(var(--color-accent));
font-size: 0.7rem;
font-weight: 500;
font-family: var(--font-mono);
}
.prose-block {
padding: 1.5rem;
border-radius: 0.75rem;
background: hsl(var(--color-surface));
}
.section-anchor {
scroll-margin-top: 5.5rem;
}
/* ── Navigation prev/next ── */
.ctx-nav {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 0.5rem;
align-items: center;
}
.ctx-nav-spacer { min-width: 0; }
.ctx-nav-btn {
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.5rem 0.875rem;
border-radius: 20px;
text-decoration: none;
font-size: 0.8rem;
font-weight: 500;
color: hsl(var(--color-text-muted));
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-text) / 0.08);
transition: all 0.15s;
min-width: 0;
}
.ctx-nav-btn:hover {
color: hsl(var(--color-primary));
border-color: hsl(var(--color-primary) / 0.25);
background: hsl(var(--color-primary) / 0.05);
}
.ctx-nav-prev { justify-content: flex-start; }
.ctx-nav-next { justify-content: flex-end; }
.ctx-nav-prev span,
.ctx-nav-next span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ctx-nav-index {
justify-content: center;
background: hsl(var(--color-primary) / 0.08);
border-color: hsl(var(--color-primary) / 0.15);
color: hsl(var(--color-primary));
font-size: 0.75rem;
padding: 0.45rem 0.75rem;
white-space: nowrap;
flex-shrink: 0;
}
.ctx-nav-index:hover { background: hsl(var(--color-primary) / 0.15); }
@media (max-width: 480px) {
.ctx-nav { grid-template-columns: auto 1fr auto; }
.ctx-nav-prev span, .ctx-nav-next span { display: none; }
.ctx-nav-prev, .ctx-nav-next { padding: 0.5rem; }
}
/* ── Zone sections ── */
.sections-area {
position: relative;
}
/* ── Sidebar sommaire ── */
.page-sidebar {
display: none;
position: absolute;
top: 0;
right: 0;
width: 10.5rem;
height: 100%;
}
@media (min-width: 1300px) {
.page-sidebar { display: block; }
}
.sommaire-sidebar {
position: sticky;
top: 5.5rem;
padding: 0.875rem;
border-radius: 14px;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-text) / 0.07);
}
.sommaire-sidebar-title {
font-family: var(--font-mono);
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: hsl(var(--color-text-muted));
margin: 0 0 0.6rem;
}
.sommaire-sidebar-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.sommaire-sidebar-link {
display: flex;
align-items: baseline;
gap: 0.45rem;
padding: 0.28rem 0.4rem;
border-radius: 8px;
text-decoration: none;
font-size: 0.75rem;
color: hsl(var(--color-text-muted));
line-height: 1.4;
transition: color 0.12s, background 0.12s;
}
.sommaire-sidebar-link:hover {
color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.06);
}
.sommaire-n {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.2rem;
height: 1.2rem;
border-radius: 50%;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
font-family: var(--font-mono);
font-size: 0.6rem;
font-weight: 700;
flex-shrink: 0;
}
</style>
+207
View File
@@ -0,0 +1,207 @@
<template>
<div class="axis-scope axis-scope--cit oiseaux-scene relative overflow-hidden section-padding">
<div class="container-content">
<header class="mb-12 text-center">
<p class="relative mb-2 font-mono text-sm tracking-widest text-primary uppercase">
<!-- Phone: the title fills the width; the bird stands at the end of this line, on its baseline.
A flat bird (37-44 px tall at 72 px wide): 93 px separate this baseline from the sticky
header, and a perched bird keeps a void of its own height above it (rule f) -->
{{ content?.kicker }}<UiOiseauPose nom="potier" pose="perche" bout :w="72" mobile-seul />
</p>
<h1 class="page-title relative font-display font-bold tracking-tight" style="color: hsl(var(--color-text))">
<!-- At the end of the title, on its baseline (rule e, docs/da-oiseaux.md) -->
{{ content?.title }}<UiOiseauPose nom="vigie" pose="perche" bout left="16px" :w="96" retourne :seuil="1200" />
</h1>
<p class="mt-4 mx-auto max-w-2xl leading-relaxed" style="color: hsl(var(--color-text-muted))">
{{ content?.description }}
</p>
</header>
<div class="mx-auto max-w-3xl flex flex-col gap-6">
<!-- Decision collective — the card clips its corners, the birds stand on a wrapper -->
<div class="relative">
<UiOiseauPose nom="juge" pose="marge-d" ecart="40px" :w="84" retourne />
<div class="item-card">
<NuxtLink to="/citoyenne/decision" class="item-overlay" aria-label="Décision collective" tabindex="-1" />
<div class="item-body group">
<div class="item-header">
<div class="item-icon">
<div class="i-lucide-gavel h-5 w-5" />
</div>
<h2 class="font-display text-xl font-bold" style="color: hsl(var(--color-text))">
Décision collective
</h2>
<UiStatusBadge class="ml-auto" v-bind="statusOf('decision')" />
</div>
<p class="leading-relaxed mt-3" style="color: hsl(var(--color-text-muted))">
Se donner les moyens de la décision collective.
</p>
</div>
<div class="item-actions">
<a :href="decisionUrl" target="_blank" rel="noopener" class="action-btn action-btn--primary">
<div class="i-lucide-external-link h-3.5 w-3.5" />
Ouvrir libreDecision
</a>
</div>
</div>
</div>
<!-- Tarifs de l'eau -->
<div class="relative">
<!-- 24 px between the two cards: no room above them for a perched bird (DA rule of 03/11) -->
<!-- Out by the side of the card, not by its corners: the card is a link (rule h); its feet
46 px above the card's bottom, where the captain stands on the other side (rule c) -->
<UiOiseauPose nom="cartographe" pose="derriere" left="calc(100% - 100px)" bottom="46px" :w="210" />
<UiOiseauPose nom="capitaine" pose="marge-g" ecart="46px" :w="136" />
<div class="item-card">
<NuxtLink to="/citoyenne/tarifs-eau" class="item-overlay" aria-label="Tarifs de l'eau" tabindex="-1" />
<div class="item-body group">
<div class="item-header">
<div class="item-icon">
<div class="i-lucide-droplets h-5 w-5" />
</div>
<h2 class="font-display text-xl font-bold" style="color: hsl(var(--color-text))">
Tarifs de l'eau
</h2>
<UiStatusBadge class="ml-auto" v-bind="statusOf('tarifs-eau')" />
</div>
<p class="leading-relaxed mt-3" style="color: hsl(var(--color-text-muted))">
Application pour obtenir justice sociale et incitation dynamique à la réduction.
Permet de confier la décision à la population des communes.
</p>
</div>
<div class="item-actions">
<a :href="sejeteral0Url" target="_blank" rel="noopener" class="action-btn action-btn--primary">
<div class="i-lucide-external-link h-3.5 w-3.5" />
Lancer SejeteralO
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'default',
})
const { data: content } = await usePageContent('citoyenne')
const statusOf = (id: string) => {
const item = ((content.value as any)?.items ?? []).find((i: any) => i.id === id) ?? {}
return { status: item.status, since: item.since, gestation: item.gestation }
}
const appConfig = useAppConfig()
const decisionUrl = (appConfig.libredecision as { url: string })?.url ?? '#'
const sejeteral0Url = (appConfig.sejeteral0 as { url: string })?.url ?? '#'
useSeoPage({
title: content.value?.meta?.title ?? 'Autonomie citoyenne',
description: (content.value as Record<string, unknown> | null)?.description as string ?? 'Décision collective, gouvernance locale, outils citoyens — l\'autonomie politique à l\'échelle des bassins de vie.',
})
</script>
<style scoped>
.page-title {
font-size: clamp(2rem, 5vw, 2.75rem);
}
.item-card {
border-radius: 16px;
border: none;
background: hsl(var(--color-surface));
transition: border-color 0.2s;
overflow: hidden;
position: relative;
cursor: pointer;
}
.item-card:hover {
border-color: hsl(var(--color-primary) / 0.2);
}
/* Lien overlay — couvre toute la carte, AU-DESSUS du contenu */
.item-overlay {
position: absolute;
inset: 0;
z-index: 1;
}
.item-body {
display: block;
padding: 1.5rem;
color: inherit;
}
.item-header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
.item-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.5rem;
background: hsl(var(--color-primary) / 0.15);
color: hsl(var(--color-primary));
box-shadow: 0 0 12px hsl(var(--color-primary) / 0.12);
flex-shrink: 0;
}
.gestation-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-left: auto;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
background: hsl(var(--color-accent) / 0.12);
color: hsl(var(--color-accent));
font-size: 0.7rem;
font-weight: 500;
font-family: var(--font-mono);
}
.item-actions {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
padding: 0.75rem 1.5rem;
border-top: 1px solid hsl(var(--color-text) / 0.06);
background: hsl(var(--color-bg) / 0.4);
position: relative;
z-index: 2;
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: 0.5rem;
font-size: 0.8rem;
font-weight: 500;
text-decoration: none;
transition: all 0.2s;
cursor: pointer;
}
.action-btn--primary {
color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.12);
border: 1px solid hsl(var(--color-primary) / 0.25);
}
.action-btn--primary:hover {
background: hsl(var(--color-primary) / 0.2);
border-color: hsl(var(--color-primary) / 0.4);
}
</style>
+146
View File
@@ -0,0 +1,146 @@
<template>
<div class="oiseaux-scene relative overflow-hidden section-padding">
<div class="container-content">
<div class="mx-auto max-w-2xl">
<div class="section-icon relative mx-auto mb-6">
<div class="i-lucide-shopping-bag h-12 w-12" />
</div>
<p class="relative mb-2 font-mono text-sm tracking-widest text-primary uppercase text-center">
<!-- Phone: the title fills the width; the bird stands at the end of this line, on its baseline -->
{{ content?.kicker }}<UiOiseauPose nom="lectrice" pose="perche" bout :w="84" retourne mobile-seul />
</p>
<h1 class="relative font-display text-3xl font-bold mb-4 text-center" style="color: hsl(var(--color-text))">
{{ content?.title }}
</h1>
<p class="text-lg leading-relaxed mb-10 text-center" style="color: hsl(var(--color-text-muted))">
{{ content?.description }}
</p>
<!-- Bookelis CTA -->
<div class="cta-block mb-8">
<UiOiseauPose nom="cycliste-livreur" pose="marge-g" ecart="54px" :w="150" />
<!-- The reader in the right margin, feet on the card's top edge: on the bag, under the
sticky header, she had 22 px of void above her (rule f, DA 03/11 after 17h35) -->
<UiOiseauPose nom="lectrice" pose="marge-d" ecart="24px" bottom="100%" :w="84" retourne />
<div class="cta-icon">
<div class="i-lucide-globe h-5 w-5" />
</div>
<div class="flex-1">
<h2 class="font-display text-lg font-semibold mb-1" style="color: hsl(var(--color-text))">
Commander en ligne
</h2>
<p class="text-sm mb-3" style="color: hsl(var(--color-text-muted))">
Impression à la demande, livraison chez vous.
</p>
<a
:href="content?.bookelis?.url"
target="_blank"
rel="noopener"
class="order-btn"
>
<div class="i-lucide-external-link h-4 w-4" />
{{ content?.bookelis?.label }}
</a>
</div>
</div>
<!-- Librairie -->
<div class="cta-block mb-10">
<UiOiseauPose nom="libraire" pose="derriere" left="calc(100% - 96px)" bottom="-36px" :w="214" retourne />
<div class="cta-icon cta-icon--accent">
<div class="i-lucide-store h-5 w-5" />
</div>
<div class="flex-1">
<h2 class="font-display text-lg font-semibold mb-2" style="color: hsl(var(--color-text))">
{{ content?.librairie?.title }}
</h2>
<p class="leading-relaxed whitespace-pre-line" style="color: hsl(var(--color-text-muted))">
{{ content?.librairie?.text }}
</p>
</div>
</div>
<div class="text-center">
<UiBaseButton variant="ghost" to="/economique">
<div class="i-lucide-arrow-left mr-2 h-4 w-4" />
Autonomie économique
</UiBaseButton>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const { data: content } = await usePageContent('economique/commande')
useSeoPage({
title: content.value?.meta?.title ?? 'Commander le livre — Une économie du don',
description: 'Commander l\'édition papier du livre « Une économie du don — enfin concevable » d\'Yvv.',
image: '/images/Couv-Economie-du-don.jpg',
})
</script>
<style scoped>
.section-icon {
display: flex;
align-items: center;
justify-content: center;
width: 5rem;
height: 5rem;
border-radius: 1rem;
background: hsl(var(--color-primary) / 0.1);
border: 1px solid hsl(var(--color-primary) / 0.2);
color: hsl(var(--color-primary));
}
.cta-block {
position: relative;
display: flex;
gap: 1rem;
padding: 1.5rem;
border-radius: 0.75rem;
border: 1px solid hsl(var(--color-text) / 0.08);
background: hsl(var(--color-surface));
}
.cta-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 0.5rem;
background: hsl(var(--color-primary) / 0.12);
color: hsl(var(--color-primary));
flex-shrink: 0;
margin-top: 0.125rem;
}
.cta-icon--accent {
background: hsl(var(--color-accent) / 0.12);
color: hsl(var(--color-accent));
}
.order-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 600;
text-decoration: none;
background: hsl(var(--color-primary));
color: white;
transition: all 0.2s;
}
.order-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px hsl(var(--color-primary) / 0.3);
}
</style>
+214
View File
@@ -0,0 +1,214 @@
<template>
<div class="axis-scope axis-scope--eco oiseaux-scene relative overflow-hidden section-padding">
<div class="container-content">
<!-- Header -->
<header class="mb-12 text-center">
<p class="relative mb-2 font-mono text-sm tracking-widest text-primary uppercase">
<!-- Phone: the title fills the width; the bird stands at the end of this line, on its baseline.
A flat bird (37-44 px tall at 72 px wide): 93 px separate this baseline from the sticky
header, and a perched bird keeps a void of its own height above it (rule f) -->
{{ content?.kicker }}<UiOiseauPose nom="maraichere" pose="perche" bout :w="72" retourne mobile-seul />
</p>
<h1 class="page-title font-display font-bold tracking-tight" style="color: hsl(var(--color-text))">
{{ content?.title }}
</h1>
<p class="mt-4 mx-auto max-w-2xl leading-relaxed" style="color: hsl(var(--color-text-muted))">
{{ content?.description }}
</p>
</header>
<div class="mx-auto max-w-3xl flex flex-col gap-8">
<!-- Monnaie libre — the card clips its Ğ1 watermark, so the bird stands on a wrapper -->
<div class="relative">
<UiOiseauPose nom="pompeur-de-g1" pose="marge-d" ecart="34px" :w="126" retourne />
<NuxtLink to="/economique/monnaie-libre" class="item-card g1-card group">
<span class="g1-watermark" aria-hidden="true">Ğ1</span>
<div class="item-header">
<div class="item-icon">
<span class="g1-icon">Ğ1</span>
</div>
<h2 class="font-display text-xl font-bold" style="color: hsl(var(--color-text))">
Monnaie libre
</h2>
<UiStatusBadge class="ml-auto" v-bind="statusOf('monnaie-libre')" />
</div>
<p class="leading-relaxed mt-3" style="color: hsl(var(--color-text-muted))">
La Ğ1 (June) : une monnaie co-créée par ses membres, sans dette ni intérêt. Le dividende universel comme base.
</p>
<div class="mt-3 inline-flex items-center gap-1 text-sm text-primary group-hover:text-primary/80 transition-colors">
En savoir plus
<div class="i-lucide-arrow-right h-3.5 w-3.5" />
</div>
</NuxtLink>
</div>
<!-- Modèle économique — bloc livre -->
<div class="book-block">
<!-- The balance (the measure of the gift) peeks out from behind the book, on the left and
halfway up: the market gardener stands low on the right, never at its height, and the
four birds of the page do not alternate at an even step -->
<UiOiseauPose nom="la-balance" pose="derriere" left="-150px" bottom="calc(100% - 240px)" :w="240" />
<!-- Not by the Productions card: with the beekeeper, a pair at the same height -->
<UiOiseauPose nom="maraichere" pose="marge-d" ecart="46px" :w="108" retourne />
<HomeBookSection
compact
@open-player="showBookPlayer = true"
@open-pdf="showPdfReader = true"
/>
</div>
<!-- Productions collectives -->
<NuxtLink to="/economique/productions-collectives" class="item-card group">
<UiOiseauPose nom="apicultrice" pose="marge-g" ecart="52px" :w="124" />
<div class="item-header">
<div class="item-icon">
<div class="i-lucide-users h-5 w-5" />
</div>
<h2 class="font-display text-xl font-bold" style="color: hsl(var(--color-text))">
Productions collectives
</h2>
<UiStatusBadge class="ml-auto" v-bind="statusOf('productions-collectives')" />
</div>
<p class="leading-relaxed mt-3" style="color: hsl(var(--color-text-muted))">
Une plateforme pour faciliter la création d'équipes et la réalisation de productions à l'échelle des bassins de vie. Passer la seconde.
</p>
<div class="mt-3 inline-flex items-center gap-1 text-sm text-primary group-hover:text-primary/80 transition-colors">
En savoir plus
<div class="i-lucide-arrow-right h-3.5 w-3.5" />
</div>
</NuxtLink>
</div>
</div>
<BookPlayer v-model="showBookPlayer" />
<BookPdfReader v-model="showPdfReader" />
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'default',
})
const { data: content } = await usePageContent('economique')
const statusOf = (id: string) => {
const item = ((content.value as any)?.items ?? []).find((i: any) => i.id === id) ?? {}
return { status: item.status, since: item.since, gestation: item.gestation }
}
useSeoPage({
title: content.value?.meta?.title ?? 'Autonomie économique',
description: (content.value as Record<string, unknown> | null)?.description as string ?? 'Comprendre et expérimenter une économie fondée sur le don, la monnaie libre et la réciprocité.',
})
const showBookPlayer = ref(false)
const showPdfReader = ref(false)
</script>
<style scoped>
.page-title {
font-size: clamp(2rem, 5vw, 2.75rem);
}
.item-card {
position: relative;
display: block;
padding: 1.5rem;
border-radius: 16px;
border: none;
background: hsl(var(--color-surface));
transition: border-color 0.2s, transform 0.12s ease;
text-decoration: none;
}
.item-card:hover {
border-color: hsl(var(--color-primary) / 0.2);
transform: translateY(-3px);
box-shadow: 0 8px 24px hsl(var(--color-primary) / 0.08);
}
.item-header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
.g1-icon {
font-family: var(--font-display);
font-weight: 700;
font-size: 1.1rem;
line-height: 1;
}
.item-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.5rem;
background: hsl(var(--color-primary) / 0.15);
color: hsl(var(--color-primary));
box-shadow: 0 0 12px hsl(var(--color-primary) / 0.12);
flex-shrink: 0;
}
.gestation-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-left: auto;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
background: hsl(var(--color-accent) / 0.12);
color: hsl(var(--color-accent));
font-size: 0.7rem;
font-weight: 500;
font-family: var(--font-mono);
}
.book-block {
position: relative;
padding: 1.25rem;
border-radius: 16px;
border: none;
background: hsl(var(--color-surface));
}
/* Monnaie libre card */
.g1-card {
position: relative;
overflow: hidden;
background: linear-gradient(
135deg,
hsl(var(--color-surface)) 60%,
hsl(var(--color-primary) / 0.06) 100%
);
border-color: hsl(var(--color-primary) / 0.14);
}
.g1-card:hover {
border-color: hsl(var(--color-primary) / 0.3);
}
.g1-watermark {
position: absolute;
right: -0.25rem;
top: 50%;
transform: translateY(-50%);
font-family: var(--font-display);
font-weight: 900;
font-size: 5.5rem;
line-height: 1;
color: hsl(var(--color-primary) / 0.07);
pointer-events: none;
user-select: none;
letter-spacing: -0.02em;
transition: color 0.2s;
}
.g1-card:hover .g1-watermark {
color: hsl(var(--color-primary) / 0.11);
}
</style>
@@ -1,5 +1,11 @@
<template> <template>
<div v-if="chapter"> <div v-if="chapter" class="relative">
<!-- The book's trades in the margins, each on a line of the chapter (rule e): the two who
write keep their way, on the left, the calligrapher on the title's rule (in the chapter
header), the typographer on the navigation's rule; the box maker in the right margin, by
the PDF and songs buttons (in the header); the proofreader on the right, by the last
paragraph -->
<UiOiseauPose nom="correcteur" pose="libre" left="calc(100% + 40px)" bottom="140px" :w="108" retourne />
<BookChapterHeader <BookChapterHeader
:title="chapter.title" :title="chapter.title"
:description="chapter.description" :description="chapter.description"
@@ -10,11 +16,14 @@
<BookChapterContent :content="chapter" /> <BookChapterContent :content="chapter" />
<!-- Prev / Next navigation --> <!-- Prev / Next navigation. The rule above it is drawn (border-t alone sets no style here):
<nav class="mt-16 flex items-center justify-between border-t border-white/8 pt-8"> the typographer stands on it, in the left margin. Perched in the column, the box maker had
20 to 27 px of void under the last paragraph (rule f) -->
<nav class="relative mt-16 flex items-center justify-between border-t border-t-solid border-white/8 pt-8">
<UiOiseauPose nom="typographe" pose="marge-g" ecart="64px" bottom="100%" :w="100" />
<NuxtLink <NuxtLink
v-if="prevChapter" v-if="prevChapter"
:to="`/modele-eco/${prevChapter.stem?.split('/').pop()}`" :to="`/economique/modele-eco/${prevChapter.stem?.split('/').pop()}`"
class="btn-ghost gap-2" class="btn-ghost gap-2"
> >
<div class="i-lucide-arrow-left h-4 w-4" /> <div class="i-lucide-arrow-left h-4 w-4" />
@@ -24,7 +33,7 @@
<NuxtLink <NuxtLink
v-if="nextChapter" v-if="nextChapter"
:to="`/modele-eco/${nextChapter.stem?.split('/').pop()}`" :to="`/economique/modele-eco/${nextChapter.stem?.split('/').pop()}`"
class="btn-ghost gap-2" class="btn-ghost gap-2"
> >
<span class="text-sm">{{ nextChapter.title }}</span> <span class="text-sm">{{ nextChapter.title }}</span>
@@ -43,6 +52,11 @@ definePageMeta({
const route = useRoute() const route = useRoute()
const slug = route.params.slug as string const slug = route.params.slug as string
// Scroll to top when navigating between chapters (component reused by Vue Router)
watch(() => slug, () => {
if (import.meta.client) window.scrollTo({ top: 0, behavior: 'instant' })
})
// Initialize guided mode // Initialize guided mode
useGuidedMode() useGuidedMode()
@@ -54,8 +68,11 @@ if (!chapter.value) {
throw createError({ statusCode: 404, statusMessage: 'Chapitre non trouvé' }) throw createError({ statusCode: 404, statusMessage: 'Chapitre non trouvé' })
} }
useHead({ useSeoPage({
title: chapter.value?.title, title: chapter.value?.title ?? slug,
description: chapter.value?.description as string ?? `Chapitre « ${chapter.value?.title} » — Une économie du don, enfin concevable.`,
image: '/images/Couv-Economie-du-don.jpg',
type: 'article',
}) })
// Get adjacent chapters for navigation // Get adjacent chapters for navigation
+128
View File
@@ -0,0 +1,128 @@
<template>
<div class="oiseaux-scene relative overflow-hidden section-padding">
<div class="container-content">
<!-- Page de couverture du livre -->
<HomeBookSection
class="mb-10 hero-compact"
:show-chapters="false"
@open-player="showBookPlayer = true"
@open-pdf="showPdfReader = true"
/>
<header class="mb-10 text-center">
<p class="relative mb-2 font-mono text-sm tracking-widest text-primary uppercase">
<!-- Phone: the title fills the width; the bird stands at the end of this line, on its baseline -->
{{ content?.kicker }}<UiOiseauPose nom="relieur" pose="perche" bout :w="72" mobile-seul />
</p>
<h1 class="page-title relative font-display font-bold tracking-tight" style="color: hsl(var(--color-text))">
<!-- Before the title, on its baseline (rule e, docs/da-oiseaux.md) -->
<UiOiseauPose nom="relieur" pose="perche" bout right="16px" :w="108" :seuil="1200" />{{ content?.title }}
</h1>
<p class="mt-3 mx-auto max-w-2xl text-sm" style="color: hsl(var(--color-text-muted))">
{{ content?.description }}
</p>
</header>
<div class="mx-auto max-w-3xl">
<ul class="flex flex-col gap-3">
<li
v-for="chapter in chapters"
:key="chapter.path"
class="relative"
>
<UiOiseauPose v-for="o in OISEAUX_CHAPITRE[chapter.stem?.split('/').pop() ?? ''] ?? []" :key="`${o.nom}-${o.pose}`" v-bind="o" />
<NuxtLink
:to="`/economique/modele-eco/${chapter.stem?.split('/').pop()}`"
class="card-surface flex items-start gap-4 group"
>
<span class="font-mono text-2xl font-bold text-primary/30 leading-none mt-1 w-10 text-right flex-shrink-0">
{{ String(chapter.order).padStart(2, '0') }}
</span>
<div class="min-w-0 flex-1">
<div class="flex items-baseline gap-2 flex-wrap">
<h2 class="font-display text-lg font-semibold text-white group-hover:text-primary transition-colors">
{{ chapter.title }}
</h2>
<span v-if="chapter.page" class="text-xs font-mono text-white/30 flex-shrink-0">p.&nbsp;{{ chapter.page }}</span>
</div>
<p v-if="chapter.description" class="mt-1 text-sm text-white/50">
{{ chapter.description }}
</p>
<div class="mt-2 flex items-center gap-3">
<span v-if="chapter.readingTime" class="text-xs text-white/30">
<span class="i-lucide-clock inline-block h-3 w-3 mr-1 align-middle" />
{{ chapter.readingTime }}
</span>
<SongBadges :chapter-slug="chapter.stem?.split('/').pop() ?? ''" />
</div>
</div>
<div class="i-lucide-chevron-right h-5 w-5 text-white/20 group-hover:text-primary/60 transition-colors flex-shrink-0 mt-2" />
</NuxtLink>
</li>
</ul>
</div>
</div>
<BookPlayer v-model="showBookPlayer" />
<BookPdfReader v-model="showPdfReader" />
</div>
</template>
<script setup lang="ts">
import type { OiseauPlace } from '~/utils/oiseauPose'
// Birds along the chapters, each by the one its trade tells (irregular intervals). The cards
// stand 12 px apart: no room above them for a perched bird (DA rule of 03/11)
const OISEAUX_CHAPITRE: Record<string, OiseauPlace[]> = {
'01-introduction': [{ nom: 'conteuse', pose: 'marge-g', ecart: '50px', w: 122 }],
'03-mesure': [{ nom: 'la-balance', pose: 'marge-d', ecart: '40px', w: 112, retourne: true }],
'07-echanger': [{ nom: 'colporteur', pose: 'marge-g', ecart: '44px', w: 116 }],
'08-institution': [{ nom: 'empileur', pose: 'marge-d', ecart: '90px', w: 104, retourne: true }],
'11-annexes': [{ nom: 'illustratrice', pose: 'derriere', left: 'calc(100% - 110px)', bottom: '-40px', w: 220 }],
}
definePageMeta({
layout: 'default',
})
const { data: content } = await usePageContent('economique/modele-eco')
useSeoPage({
title: content.value?.meta?.title ?? 'Une économie du don — Table des matières',
description: 'Onze chapitres pour explorer les fondements d\'une économie fondée sur le don, la mesure et la monnaie libre.',
image: '/images/Couv-Economie-du-don.jpg',
type: 'book',
})
const { data: chapters } = await useAsyncData('book-toc', () =>
queryCollection('book').order('order', 'ASC').all(),
)
const showBookPlayer = ref(false)
const showPdfReader = ref(false)
</script>
<style scoped>
.page-title {
font-size: clamp(1.5rem, 3.5vw, 2rem);
}
/* Compact the HomeBookSection hero */
:deep(.hero-compact .book-hero),
:deep(.hero-compact .section-hero) {
padding-top: 2rem;
padding-bottom: 2rem;
}
:deep(.hero-compact img),
:deep(.hero-compact .book-cover) {
max-height: 260px;
}
:deep(.hero-compact h1),
:deep(.hero-compact .hero-title) {
font-size: clamp(1.4rem, 3vw, 2rem);
}
:deep(.hero-compact .hero-description) {
font-size: 0.9rem;
}
</style>
+331
View File
@@ -0,0 +1,331 @@
<template>
<div class="oiseaux-scene axis-scope axis-scope--eco section-padding">
<div class="container-content">
<!-- Header -->
<div class="mx-auto max-w-2xl text-center mb-8">
<div class="section-icon mx-auto mb-6">
<span v-if="content?.icon === 'g1'" class="g1-icon">Ğ1</span>
<div v-else :class="`i-lucide-${content?.icon ?? 'coins'}`" class="h-12 w-12" />
</div>
<p class="mb-2 font-mono text-sm tracking-widest text-primary uppercase">
{{ content?.kicker }}
</p>
<h1 class="font-display text-3xl font-bold mb-4" style="color: hsl(var(--color-text))">
{{ content?.title }}
</h1>
<p class="text-lg leading-relaxed" style="color: hsl(var(--color-text-muted))">
{{ content?.description }}
</p>
<UiStatusBadge class="mt-4" :status="content?.status" :since="content?.since" />
</div>
<!-- Navigation prev / index / next -->
<nav class="ctx-nav mx-auto max-w-2xl mb-10">
<NuxtLink v-if="prevItem?.to" :to="prevItem.to" class="ctx-nav-btn ctx-nav-prev">
<div class="i-lucide-arrow-left h-4 w-4 shrink-0" />
<span>{{ prevItem.label }}</span>
</NuxtLink>
<div v-else class="ctx-nav-spacer" />
<NuxtLink to="/economique" class="ctx-nav-btn ctx-nav-index">
<div class="i-lucide-layout-grid h-4 w-4" />
<span>Économique</span>
</NuxtLink>
<NuxtLink v-if="nextItem?.to" :to="nextItem.to" class="ctx-nav-btn ctx-nav-next">
<span>{{ nextItem.label }}</span>
<div class="i-lucide-arrow-right h-4 w-4 shrink-0" />
</NuxtLink>
<div v-else class="ctx-nav-spacer" />
</nav>
<!-- Zone sections — relative pour ancrer le sidebar -->
<div class="sections-area">
<!-- Sidebar sommaire -->
<aside v-if="sommaire.length > 1" class="page-sidebar">
<nav class="sommaire-sidebar">
<p class="sommaire-sidebar-title">Sur cette page</p>
<ol class="sommaire-sidebar-list">
<li v-for="(entry, ei) in sommaire" :key="ei">
<a :href="`#${entry.id}`" class="sommaire-sidebar-link">
<span class="sommaire-n">{{ ei + 1 }}</span>
<span>{{ entry.title }}</span>
</a>
</li>
</ol>
</nav>
</aside>
<!-- Content -->
<div
v-if="content?.content"
id="section-content"
class="prose-block relative mb-8 max-w-2xl mx-auto section-anchor"
>
<UiOiseauPose nom="boulangere" pose="marge-g" ecart="56px" :w="96" />
<!-- Behind the top of the text block, which has a background (the links stand apart) -->
<UiOiseauPose nom="vigneronne" pose="derriere" left="-140px" bottom="calc(100% - 220px)" :w="210" />
<p class="leading-relaxed whitespace-pre-line" style="color: hsl(var(--color-text-muted))">
{{ content.content }}
</p>
</div>
<!-- External links -->
<div
v-if="content?.links"
id="section-links"
class="relative flex flex-col gap-3 mb-10 max-w-2xl mx-auto section-anchor"
>
<a
v-for="link in content.links"
:key="link.href"
:href="link.href"
target="_blank"
rel="noopener"
class="link-card group"
>
<div class="link-icon">
<div :class="`i-lucide-${link.icon ?? 'external-link'} h-4 w-4`" />
</div>
<span class="text-sm font-medium" style="color: hsl(var(--color-text))">{{ link.label }}</span>
<div class="i-lucide-arrow-up-right h-3.5 w-3.5 ml-auto text-primary/40 group-hover:text-primary transition-colors" />
</a>
</div>
</div><!-- /sections-area -->
</div>
</div>
</template>
<script setup lang="ts">
const [{ data: content }, { data: homeData }] = await Promise.all([
usePageContent('economique/monnaie-libre'),
usePageContent('home'),
])
// Sommaire
const sommaire = computed(() => {
const entries: { title: string; id: string }[] = []
if (content.value?.content) entries.push({ title: 'La théorie', id: 'section-content' })
if ((content.value?.links as unknown[])?.length) entries.push({ title: 'Ressources', id: 'section-links' })
return entries
})
// Prev / next dans la section économique
interface AxisItem { label: string; to?: string; icon?: string }
const economieItems = computed<AxisItem[]>(
() => (homeData.value as Record<string, unknown> | null)?.axes?.economie?.items as AxisItem[] ?? [],
)
const currentPath = '/economique/monnaie-libre'
const currentIdx = computed(() => economieItems.value.findIndex(i => i.to === currentPath))
const prevItem = computed(() => currentIdx.value > 0 ? economieItems.value[currentIdx.value - 1] : null)
const nextItem = computed(() => currentIdx.value < economieItems.value.length - 1 ? economieItems.value[currentIdx.value + 1] : null)
useSeoPage({
title: content.value?.meta?.title ?? 'Monnaie libre — Autonomie économique',
description: content.value?.description as string ?? 'La monnaie libre (June / G1) : comprendre la théorie relative de la monnaie et expérimenter une monnaie équitable.',
})
</script>
<style scoped>
.g1-icon {
font-family: var(--font-display);
font-weight: 700;
font-size: 1.75rem;
line-height: 1;
}
.section-icon {
display: flex;
align-items: center;
justify-content: center;
width: 5rem;
height: 5rem;
border-radius: 1rem;
background: hsl(var(--color-primary) / 0.1);
border: 1px solid hsl(var(--color-primary) / 0.2);
color: hsl(var(--color-primary));
}
.prose-block {
padding: 1.5rem;
border-radius: 0.75rem;
background: hsl(var(--color-surface));
}
.link-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
border: 1px solid hsl(var(--color-primary) / 0.1);
background: hsl(var(--color-surface));
text-decoration: none;
transition: border-color 0.2s;
}
.link-card:hover {
border-color: hsl(var(--color-primary) / 0.25);
}
.link-icon {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 0.375rem;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
flex-shrink: 0;
}
.section-anchor {
scroll-margin-top: 5.5rem;
}
/* ── Navigation prev/next ── */
.ctx-nav {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 0.5rem;
align-items: center;
}
.ctx-nav-spacer { min-width: 0; }
.ctx-nav-btn {
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.5rem 0.875rem;
border-radius: 20px;
text-decoration: none;
font-size: 0.8rem;
font-weight: 500;
color: hsl(var(--color-text-muted));
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-text) / 0.08);
transition: all 0.15s;
min-width: 0;
}
.ctx-nav-btn:hover {
color: hsl(var(--color-primary));
border-color: hsl(var(--color-primary) / 0.25);
background: hsl(var(--color-primary) / 0.05);
}
.ctx-nav-prev { justify-content: flex-start; }
.ctx-nav-next { justify-content: flex-end; }
.ctx-nav-prev span,
.ctx-nav-next span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ctx-nav-index {
justify-content: center;
background: hsl(var(--color-primary) / 0.08);
border-color: hsl(var(--color-primary) / 0.15);
color: hsl(var(--color-primary));
font-size: 0.75rem;
padding: 0.45rem 0.75rem;
white-space: nowrap;
flex-shrink: 0;
}
.ctx-nav-index:hover { background: hsl(var(--color-primary) / 0.15); }
@media (max-width: 480px) {
.ctx-nav { grid-template-columns: auto 1fr auto; }
.ctx-nav-prev span, .ctx-nav-next span { display: none; }
.ctx-nav-prev, .ctx-nav-next { padding: 0.5rem; }
}
/* ── Zone sections ── */
.sections-area {
position: relative;
}
/* ── Sidebar sommaire ── */
.page-sidebar {
display: none;
position: absolute;
top: 0;
right: 0;
width: 10.5rem;
height: 100%;
}
@media (min-width: 1300px) {
.page-sidebar { display: block; }
}
.sommaire-sidebar {
position: sticky;
top: 5.5rem;
padding: 0.875rem;
border-radius: 14px;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-text) / 0.07);
}
.sommaire-sidebar-title {
font-family: var(--font-mono);
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: hsl(var(--color-text-muted));
margin: 0 0 0.6rem;
}
.sommaire-sidebar-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.sommaire-sidebar-link {
display: flex;
align-items: baseline;
gap: 0.45rem;
padding: 0.28rem 0.4rem;
border-radius: 8px;
text-decoration: none;
font-size: 0.75rem;
color: hsl(var(--color-text-muted));
line-height: 1.4;
transition: color 0.12s, background 0.12s;
}
.sommaire-sidebar-link:hover {
color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.06);
}
.sommaire-n {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.2rem;
height: 1.2rem;
border-radius: 50%;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
font-family: var(--font-mono);
font-size: 0.6rem;
font-weight: 700;
flex-shrink: 0;
}
</style>
@@ -0,0 +1,94 @@
<template>
<div class="axis-scope axis-scope--eco oiseaux-scene relative section-padding">
<div class="container-content">
<div class="mx-auto max-w-2xl">
<div class="section-icon mx-auto mb-6">
<div :class="`i-lucide-${content?.icon ?? 'users'}`" class="h-12 w-12" />
</div>
<p class="mb-2 font-mono text-sm tracking-widest text-primary uppercase text-center">
{{ content?.kicker }}
</p>
<!-- The workshop: each trade of the collective productions around the page -->
<h1 class="relative font-display text-3xl font-bold mb-4 text-center" style="color: hsl(var(--color-text))">
{{ content?.title }}
<!-- In the margin, feet on the title's baseline (rule e) -->
<UiOiseauPose nom="soudeuse" pose="marge-d" ecart="30px" bottom="4px" :w="112" retourne />
</h1>
<p class="relative text-lg leading-relaxed mb-8 text-center" style="color: hsl(var(--color-text-muted))">
{{ content?.description }}
</p>
<div class="mb-8 text-center">
<UiStatusBadge :status="content?.status" :gestation="content?.gestation" />
</div>
<!-- Content -->
<div v-if="content?.content" class="prose-block mb-10">
<UiOiseauPose nom="menuisier" pose="perche" left="20px" :w="84" />
<!-- Phone: a flatter bird at the same place, the text above leaves 89 px (rule f) -->
<UiOiseauPose nom="cycliste-livreur" pose="perche" left="20px" :w="72" mobile-seul />
<UiOiseauPose nom="forgeron" pose="marge-g" ecart="70px" :w="140" />
<UiOiseauPose nom="berger" pose="derriere" left="calc(100% - 110px)" bottom="-46px" :w="230" />
<p class="leading-relaxed whitespace-pre-line" style="color: hsl(var(--color-text-muted))">
{{ content.content }}
</p>
</div>
<div class="relative text-center">
<UiBaseButton variant="ghost" to="/economique">
<div class="i-lucide-arrow-left mr-2 h-4 w-4" />
Autonomie économique
</UiBaseButton>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const { data: content } = await usePageContent('economique/productions-collectives')
useSeoPage({
title: content.value?.meta?.title ?? 'Productions collectives',
description: content.value?.description as string ?? 'Initiatives et productions collectives dans le cadre de l\'économie du don.',
})
</script>
<style scoped>
.section-icon {
display: flex;
align-items: center;
justify-content: center;
width: 5rem;
height: 5rem;
border-radius: 1rem;
background: hsl(var(--color-primary) / 0.1);
border: 1px solid hsl(var(--color-primary) / 0.2);
color: hsl(var(--color-primary));
}
.gestation-badge {
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
background: hsl(var(--color-accent) / 0.12);
color: hsl(var(--color-accent));
font-size: 0.75rem;
font-weight: 500;
font-family: var(--font-mono);
width: fit-content;
}
.prose-block {
position: relative;
padding: 1.5rem;
border-radius: 0.75rem;
background: hsl(var(--color-surface));
}
</style>
+17 -110
View File
@@ -1,75 +1,11 @@
<template> <template>
<div class="relative overflow-hidden section-padding"> <div class="relative overflow-hidden section-padding">
<!-- Shadok danseur: character dancing with music notes -->
<svg class="shadok-danseur" viewBox="0 0 240 300" fill="none" aria-hidden="true">
<!-- Body (dynamic pose, leaning) -->
<ellipse cx="120" cy="155" rx="38" ry="46" fill="currentColor" opacity="0.85"/>
<!-- Head (tilted with joy) -->
<ellipse cx="125" cy="92" rx="24" ry="23" fill="currentColor" opacity="0.8"/>
<!-- Eyes (happy, squinted) -->
<path d="M114 88 Q118 84 122 88" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.4"/>
<path d="M130 88 Q134 84 138 88" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.4"/>
<!-- Big smile -->
<path d="M116 100 Q125 108 134 100" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.35"/>
<!-- Arms thrown up (dancing) -->
<line x1="85" y1="140" x2="50" y2="100" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<line x1="155" y1="140" x2="190" y2="105" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<!-- Hands -->
<circle cx="50" cy="98" r="4" fill="currentColor" opacity="0.4"/>
<circle cx="190" cy="103" r="4" fill="currentColor" opacity="0.4"/>
<!-- Legs (one kicked up) -->
<line x1="105" y1="198" x2="80" y2="255" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<line x1="135" y1="198" x2="170" y2="240" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<!-- Feet -->
<path d="M80 255 L68 258 M80 255 L75 261" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/>
<path d="M170 240 L180 238 M170 240 L175 246" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/>
<!-- Music notes floating around -->
<text x="42" y="82" fill="currentColor" opacity="0.3" font-size="18">&#9834;</text>
<text x="195" y="88" fill="currentColor" opacity="0.25" font-size="16">&#9835;</text>
<text x="60" y="65" fill="currentColor" opacity="0.2" font-size="14">&#9833;</text>
<text x="180" y="72" fill="currentColor" opacity="0.2" font-size="20">&#9834;</text>
</svg>
<!-- Shadok DJ: character with headphones behind a turntable -->
<svg class="shadok-dj" viewBox="0 0 260 300" fill="none" aria-hidden="true">
<!-- Body -->
<ellipse cx="130" cy="155" rx="42" ry="50" fill="currentColor" opacity="0.85"/>
<!-- Head -->
<circle cx="130" cy="88" r="26" fill="currentColor" opacity="0.8"/>
<!-- Headphones band -->
<path d="M104 78 Q130 55 156 78" stroke="currentColor" stroke-width="4" stroke-linecap="round" fill="none" opacity="0.6"/>
<!-- Headphone ear pads -->
<ellipse cx="102" cy="85" rx="8" ry="12" fill="currentColor" opacity="0.5"/>
<ellipse cx="158" cy="85" rx="8" ry="12" fill="currentColor" opacity="0.5"/>
<!-- Eyes (cool, half-lidded) -->
<ellipse cx="120" cy="85" rx="5" ry="3" fill="currentColor" opacity="0.25"/>
<ellipse cx="140" cy="85" rx="5" ry="3" fill="currentColor" opacity="0.25"/>
<circle cx="121" cy="86" r="1.8" fill="currentColor" opacity="0.5"/>
<circle cx="141" cy="86" r="1.8" fill="currentColor" opacity="0.5"/>
<!-- Mouth (grin) -->
<path d="M122 98 Q130 104 138 98" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.35"/>
<!-- Arms reaching to turntable -->
<line x1="90" y1="150" x2="55" y2="195" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<line x1="170" y1="150" x2="205" y2="195" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<!-- Turntable body -->
<rect x="30" y="200" width="200" height="18" rx="4" fill="currentColor" opacity="0.4"/>
<!-- Turntable platter -->
<ellipse cx="130" cy="200" rx="55" ry="15" fill="currentColor" opacity="0.25"/>
<ellipse cx="130" cy="200" rx="55" ry="15" stroke="currentColor" stroke-width="1.5" fill="none" opacity="0.35"/>
<!-- Record center -->
<circle cx="130" cy="200" r="5" fill="currentColor" opacity="0.4"/>
<!-- Tone arm -->
<line x1="195" y1="188" x2="150" y2="195" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.5"/>
<circle cx="195" cy="188" r="3" fill="currentColor" opacity="0.35"/>
<!-- Legs -->
<line x1="115" y1="202" x2="105" y2="260" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<line x1="145" y1="202" x2="155" y2="260" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
</svg>
<div class="container-content"> <div class="container-content">
<!-- Hero section with book cover --> <!-- Hero section with book cover -->
<div class="mb-12 grid items-center gap-8 md:grid-cols-2"> <div class="mb-12 grid items-center gap-8 md:grid-cols-2">
<div class="book-cover-wrapper"> <div class="book-cover-wrapper relative">
<!-- On the cover's base line, in the empty space between the book and the text -->
<UiOiseauPose nom="trompettiste" pose="libre" left="calc(100% - 112px)" bottom="0px" :w="104" />
<div class="book-cover-3d"> <div class="book-cover-3d">
<img <img
:src="homeContent?.book.coverImage" :src="homeContent?.book.coverImage"
@@ -80,8 +16,12 @@
</div> </div>
<div class="text-center md:text-left"> <div class="text-center md:text-left">
<p class="mb-2 font-mono text-sm tracking-widest text-accent uppercase">{{ content?.kicker }}</p> <p class="relative mb-2 font-mono text-sm tracking-widest text-accent uppercase">
<h1 class="page-title font-display font-bold tracking-tight text-white"> <!-- The DJ stands at the end of this line, on its baseline: the title's box has no visible
top edge (rule e) -->
{{ content?.kicker }}<UiOiseauPose nom="dj" pose="perche" bout left="14px" :w="84" retourne ton="accent" :seuil="1200" />
</p>
<h1 class="page-title relative font-display font-bold tracking-tight text-white">
{{ content?.title }} {{ content?.title }}
</h1> </h1>
<p class="mt-4 text-white/60"> <p class="mt-4 text-white/60">
@@ -131,7 +71,10 @@
</div> </div>
<!-- Song list --> <!-- Song list -->
<div v-if="viewMode === 'list'" class="flex flex-col gap-2"> <div v-if="viewMode === 'list'" class="relative flex flex-col gap-2">
<!-- S, not M: above it the buttons leave 150 px, and a perched bird keeps a void of its own
height above its head (rule f, DA 03/11 after 17h35) -->
<UiOiseauPose nom="danseur" pose="perche" left="calc(64% - 60px)" :w="80" :seuil="1200" />
<SongItem <SongItem
v-for="song in filteredSongs" v-for="song in filteredSongs"
:key="song.id" :key="song.id"
@@ -166,8 +109,10 @@ definePageMeta({
const { data: content } = await usePageContent('en-musique') const { data: content } = await usePageContent('en-musique')
const { data: homeContent } = await usePageContent('home') const { data: homeContent } = await usePageContent('home')
useHead({ useSeoPage({
title: content.value?.meta?.title ?? 'En musique', title: content.value?.meta?.title ?? 'En musique — Le Librodrome',
description: 'Neuf chansons qui racontent le livre « Une économie du don ». Écoute libre, paroles et présentation musicale guidée.',
image: '/images/Couv-Economie-du-don.jpg',
}) })
const store = usePlayerStore() const store = usePlayerStore()
@@ -235,42 +180,4 @@ const filteredSongs = computed(() => {
object-fit: cover; object-fit: cover;
transform: translateX(-50%); transform: translateX(-50%);
} }
.shadok-danseur {
position: absolute;
left: 2%;
top: 3%;
width: clamp(100px, 14vw, 200px);
opacity: 0.28;
pointer-events: none;
color: hsl(var(--color-primary));
animation: shadok-float-danseur 7s ease-in-out infinite;
}
.shadok-dj {
position: absolute;
right: 2%;
top: 3%;
width: clamp(120px, 16vw, 230px);
opacity: 0.3;
pointer-events: none;
color: hsl(var(--color-accent));
animation: shadok-float-dj 8s ease-in-out infinite;
}
@keyframes shadok-float-danseur {
0%, 100% { transform: translateY(0) rotate(0deg); }
25% { transform: translateY(-8px) rotate(2deg); }
75% { transform: translateY(-4px) rotate(-2deg); }
}
@keyframes shadok-float-dj {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
@media (max-width: 768px) {
.shadok-danseur { display: none; }
.shadok-dj { display: none; }
}
</style> </style>
+310 -226
View File
@@ -1,267 +1,351 @@
<template> <template>
<div class="relative overflow-hidden section-padding min-h-[70vh] flex items-center justify-center"> <div class="relative overflow-hidden section-padding min-h-[70vh] flex items-center justify-center">
<!-- Shadok jongleur: juggling coins (top-left) -->
<svg class="shadok-juggler" viewBox="0 0 240 300" fill="none" aria-hidden="true">
<!-- Body -->
<ellipse cx="120" cy="160" rx="38" ry="46" fill="currentColor" opacity="0.85"/>
<!-- Head -->
<circle cx="120" cy="98" r="24" fill="currentColor" opacity="0.8"/>
<!-- Eyes (looking up at coins) -->
<circle cx="112" cy="92" r="3.5" fill="currentColor" opacity="0.2"/>
<circle cx="130" cy="92" r="3.5" fill="currentColor" opacity="0.2"/>
<circle cx="113" cy="91" r="1.5" fill="currentColor" opacity="0.5"/>
<circle cx="131" cy="91" r="1.5" fill="currentColor" opacity="0.5"/>
<!-- Smile -->
<path d="M112 108 Q120 114 128 108" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.35"/>
<!-- Arms up (juggling) -->
<line x1="85" y1="145" x2="55" y2="105" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<line x1="155" y1="145" x2="185" y2="105" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<!-- Hands -->
<circle cx="55" cy="103" r="4" fill="currentColor" opacity="0.4"/>
<circle cx="185" cy="103" r="4" fill="currentColor" opacity="0.4"/>
<!-- Juggling coins -->
<circle cx="90" cy="55" r="8" fill="currentColor" opacity="0.35"/>
<text x="86" y="59" fill="currentColor" opacity="0.5" font-size="10" font-weight="bold">$</text>
<circle cx="120" cy="40" r="8" fill="currentColor" opacity="0.3"/>
<text x="116" y="44" fill="currentColor" opacity="0.45" font-size="10" font-weight="bold">$</text>
<circle cx="150" cy="50" r="8" fill="currentColor" opacity="0.32"/>
<text x="146" y="54" fill="currentColor" opacity="0.48" font-size="10" font-weight="bold">$</text>
<!-- Legs -->
<line x1="105" y1="203" x2="95" y2="260" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<line x1="135" y1="203" x2="145" y2="260" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
</svg>
<!-- Shadok échelle: on a wobbly ladder (top-right) -->
<svg class="shadok-ladder" viewBox="0 0 220 320" fill="none" aria-hidden="true">
<!-- Ladder (tilting) -->
<line x1="80" y1="50" x2="70" y2="300" stroke="currentColor" stroke-width="3" opacity="0.35"/>
<line x1="150" y1="50" x2="140" y2="300" stroke="currentColor" stroke-width="3" opacity="0.35"/>
<!-- Rungs -->
<line x1="82" y1="80" x2="148" y2="80" stroke="currentColor" stroke-width="2.5" opacity="0.3"/>
<line x1="83" y1="120" x2="147" y2="120" stroke="currentColor" stroke-width="2.5" opacity="0.3"/>
<line x1="84" y1="160" x2="146" y2="160" stroke="currentColor" stroke-width="2.5" opacity="0.3"/>
<line x1="85" y1="200" x2="145" y2="200" stroke="currentColor" stroke-width="2.5" opacity="0.3"/>
<line x1="86" y1="240" x2="144" y2="240" stroke="currentColor" stroke-width="2.5" opacity="0.3"/>
<!-- Shadok on top (arms out for balance) -->
<ellipse cx="115" cy="68" rx="18" ry="14" fill="currentColor" opacity="0.85"/>
<circle cx="115" cy="46" r="14" fill="currentColor" opacity="0.8"/>
<!-- Eyes (worried) -->
<circle cx="110" cy="43" r="3" fill="currentColor" opacity="0.25"/>
<circle cx="122" cy="43" r="3" fill="currentColor" opacity="0.25"/>
<circle cx="110" cy="44" r="1.2" fill="currentColor" opacity="0.5"/>
<circle cx="122" cy="44" r="1.2" fill="currentColor" opacity="0.5"/>
<!-- Worried mouth -->
<path d="M108 52 Q115 49 122 52" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.3"/>
<!-- Arms out (balancing) -->
<line x1="97" y1="62" x2="60" y2="55" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.6"/>
<line x1="133" y1="62" x2="170" y2="55" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.6"/>
</svg>
<!-- Shadok acrobate: doing a cartwheel (center) -->
<svg class="shadok-acrobat" viewBox="0 0 260 240" fill="none" aria-hidden="true">
<!-- Body (sideways, mid-cartwheel) -->
<ellipse cx="130" cy="120" rx="30" ry="38" fill="currentColor" opacity="0.85" transform="rotate(45 130 120)"/>
<!-- Head -->
<circle cx="155" cy="82" r="20" fill="currentColor" opacity="0.8"/>
<!-- Eyes (dizzy/happy) -->
<path d="M148 78 Q152 74 156 78" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.4"/>
<path d="M160 78 Q164 74 168 78" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.4"/>
<!-- Smile -->
<path d="M150 90 Q158 95 165 90" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.3"/>
<!-- Arms (one touching ground, one up) -->
<line x1="110" y1="100" x2="80" y2="130" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.6"/>
<line x1="150" y1="105" x2="185" y2="70" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.6"/>
<!-- Hand on ground -->
<circle cx="78" cy="132" r="4" fill="currentColor" opacity="0.4"/>
<!-- Legs (splayed in cartwheel) -->
<line x1="125" y1="155" x2="100" y2="195" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.6"/>
<line x1="140" y1="150" x2="175" y2="175" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.6"/>
<!-- Motion lines -->
<path d="M70 110 Q60 105 55 115" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.2"/>
<path d="M190 60 Q200 55 205 65" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.2"/>
</svg>
<!-- Shadok dormeur: sleeping on a cloud (bottom-left) -->
<svg class="shadok-sleeper" viewBox="0 0 260 220" fill="none" aria-hidden="true">
<!-- Cloud -->
<ellipse cx="130" cy="150" rx="80" ry="25" fill="currentColor" opacity="0.2"/>
<circle cx="80" cy="140" r="25" fill="currentColor" opacity="0.18"/>
<circle cx="120" cy="130" r="30" fill="currentColor" opacity="0.2"/>
<circle cx="165" cy="135" r="22" fill="currentColor" opacity="0.18"/>
<circle cx="190" cy="142" r="18" fill="currentColor" opacity="0.15"/>
<!-- Shadok body (lying down) -->
<ellipse cx="130" cy="125" rx="35" ry="18" fill="currentColor" opacity="0.85"/>
<!-- Head (on cloud, sideways) -->
<ellipse cx="85" cy="118" rx="18" ry="16" fill="currentColor" opacity="0.8"/>
<!-- Closed eyes (sleeping) -->
<path d="M76 115 Q80 112 84 115" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.4"/>
<path d="M88 115 Q92 112 96 115" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.4"/>
<!-- Snooze bubbles -->
<text x="70" y="100" fill="currentColor" opacity="0.3" font-size="12" font-weight="bold">z</text>
<text x="60" y="85" fill="currentColor" opacity="0.25" font-size="16" font-weight="bold">z</text>
<text x="48" y="68" fill="currentColor" opacity="0.2" font-size="20" font-weight="bold">z</text>
<!-- Legs (curled) -->
<path d="M165 125 Q180 130 175 140" stroke="currentColor" stroke-width="3" stroke-linecap="round" fill="none" opacity="0.5"/>
<path d="M160 130 Q172 138 168 148" stroke="currentColor" stroke-width="3" stroke-linecap="round" fill="none" opacity="0.5"/>
</svg>
<!-- Shadok cuisinier: cooking in a cauldron (bottom-right) -->
<svg class="shadok-cook" viewBox="0 0 240 300" fill="none" aria-hidden="true">
<!-- Body -->
<ellipse cx="120" cy="145" rx="38" ry="45" fill="currentColor" opacity="0.85"/>
<!-- Head -->
<circle cx="120" cy="85" r="24" fill="currentColor" opacity="0.8"/>
<!-- Chef hat -->
<ellipse cx="120" cy="62" rx="22" ry="18" fill="currentColor" opacity="0.35"/>
<rect x="105" y="68" width="30" height="6" rx="1" fill="currentColor" opacity="0.4"/>
<!-- Eyes (focused on cooking) -->
<circle cx="112" cy="82" r="3.5" fill="currentColor" opacity="0.2"/>
<circle cx="130" cy="82" r="3.5" fill="currentColor" opacity="0.2"/>
<circle cx="113" cy="83" r="1.5" fill="currentColor" opacity="0.5"/>
<circle cx="131" cy="83" r="1.5" fill="currentColor" opacity="0.5"/>
<!-- Tongue out (concentrating) -->
<path d="M115 96 Q120 100 125 96" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.3"/>
<!-- Arm with ladle -->
<line x1="155" y1="135" x2="185" y2="175" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<!-- Ladle -->
<line x1="185" y1="175" x2="175" y2="200" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/>
<ellipse cx="175" cy="205" rx="8" ry="5" fill="currentColor" opacity="0.35"/>
<!-- Other arm -->
<line x1="85" y1="140" x2="60" y2="175" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<!-- Legs -->
<line x1="105" y1="188" x2="95" y2="250" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<line x1="135" y1="188" x2="145" y2="250" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<!-- Cauldron -->
<path d="M55 220 Q55 260 120 260 Q185 260 185 220" fill="currentColor" opacity="0.3"/>
<ellipse cx="120" cy="220" rx="65" ry="12" fill="currentColor" opacity="0.25"/>
<ellipse cx="120" cy="220" rx="65" ry="12" stroke="currentColor" stroke-width="2" fill="none" opacity="0.35"/>
<!-- Steam -->
<path d="M95 210 Q90 195 95 185" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.2"/>
<path d="M120 208 Q118 190 122 180" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.2"/>
<path d="M145 210 Q148 195 143 185" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.2"/>
</svg>
<div class="container-content relative z-10 text-center"> <div class="container-content relative z-10 text-center">
<!-- The stage of the hero: a tightrope walker and a drummer, at different heights -->
<UiOiseauPose nom="funambule" pose="libre" left="5%" bottom="6%" :w="176" :op="0.2" />
<UiOiseauPose nom="batteur" pose="libre" right="6%" bottom="34%" :w="116" retourne ton="accent" />
<!-- Logo SVG vectoriel — inline pour currentColor -->
<div class="evt-logo-wrap mx-auto mb-6" aria-hidden="true">
<UiSectionSign class="evt-logo" />
</div>
<p class="mb-3 font-mono text-sm tracking-widest text-accent uppercase">{{ content?.kicker }}</p> <p class="mb-3 font-mono text-sm tracking-widest text-accent uppercase">{{ content?.kicker }}</p>
<h1 class="page-title font-display font-extrabold tracking-tight text-white"> <h1 class="page-title relative font-display font-extrabold tracking-tight" style="color: hsl(var(--color-text))">
{{ content?.title }} <!-- The trapeze artist at the end of the title, on its baseline (rule e, docs/da-oiseaux.md) -->
{{ content?.title }}<UiOiseauPose nom="trapeziste" pose="perche" bout left="16px" :w="96" ton="accent" :seuil="1200" />
</h1> </h1>
<p class="mt-4 text-lg text-white/50"> <p v-if="evtContent?.format" class="evt-format font-display">
{{ evtContent.format }}
</p>
<p v-if="evtContent?.subtitle" class="mt-2 text-base italic" style="color: hsl(var(--color-text-muted))">
{{ evtContent.subtitle }}
</p>
<p class="mt-4 text-lg leading-relaxed max-w-xl mx-auto" style="color: hsl(var(--color-text-muted))">
{{ content?.description }} {{ content?.description }}
</p> </p>
<!-- Leitmotiv -->
<div v-if="evtContent?.leitmotiv" class="mt-8 flex flex-col items-center gap-3">
<p class="font-display text-xl font-bold" style="color: hsl(var(--color-text))">
« {{ evtContent.leitmotiv }} »
</p>
<!-- The acrobat before the tagline, on its line: the tagline is narrower than the leitmotiv,
28 px keep it out from under the leitmotiv, and above it the description ends higher
(rule f, DA 03/11 after 17h35). By « Format & lieu », the stage manager stands alone -->
<p v-if="evtContent?.tagline" class="relative font-mono text-sm tracking-widest uppercase" style="color: hsl(var(--color-primary))">
{{ evtContent.tagline }}
<UiOiseauPose nom="acrobate" pose="perche" right="calc(100% + 28px)" bottom="0px" :w="72" />
</p>
<span v-if="evtContent?.gestation" class="evt-gestation-badge">
<div class="i-lucide-flask-conical h-3.5 w-3.5" />
En gestation
</span>
</div> </div>
</div> </div>
</div>
<!-- ══ Contenu événement ══ -->
<section v-if="evtContent?.axes || evtContent?.espaces || evtContent?.config" class="evt-content relative section-padding">
<!-- The puppeteer stands on the footer's top rule, from 1200 px: under « Espaces & programme »
the axes cards leave 100 px, half what it needs (rule f), and each row of cards holds one
bird (rule c). The only bird of the page out of its block (orch, 03/11, option 1). Its
offset is the seal's block of the layout (--seal-block), between this section and the
footer; the bird check verifies it stands on the rule -->
<UiOiseauPose nom="marionnettiste" pose="perche" left="18%" bottom="calc(-1 * var(--seal-block))" :w="112" retourne :seuil="1200" />
<div class="container-content flex flex-col gap-14">
<!-- 3 axes -->
<div v-if="evtContent?.axes">
<h2 class="evt-section-title relative">
<!-- The clown at the end of its title, on its baseline (rule e). M: between 768 and
1199 px the four birds shown would all be S otherwise (rule a) -->
Trois axes d'émancipation<UiOiseauPose nom="clown" pose="perche" bout left="14px" :w="108" retourne ton="accent" />
</h2>
<p class="evt-section-sub">« je subis — ou je m'affranchis »</p>
<div class="axes-grid">
<div v-for="(axe, i) in evtContent.axes" :key="i" class="axe-card">
<UiOiseauPose v-for="o in OISEAUX_CARTE[axe.label] ?? []" :key="`${o.nom}-${o.pose}`" v-bind="o" />
<div class="axe-icon">
<div :class="`i-lucide-${axe.icon} h-5 w-5`" />
</div>
<h3 class="font-display text-lg font-bold mb-3" style="color: hsl(var(--color-text))">
{{ axe.label }}
</h3>
<ul class="axe-list">
<li v-for="(item, j) in axe.items" :key="j">
<div class="i-lucide-arrow-right h-3.5 w-3.5 shrink-0 mt-0.5" style="color: hsl(var(--color-primary))" />
<span>{{ item }}</span>
</li>
</ul>
</div>
</div>
</div>
<!-- Espaces permanents -->
<div v-if="evtContent?.espaces">
<h2 class="evt-section-title relative">
Espaces & programme
</h2>
<p class="evt-section-sub">Chorégraphie séquencée de plénières, ateliers et espaces permanents</p>
<div class="espaces-grid">
<div v-for="(esp, i) in evtContent.espaces" :key="i" class="espace-card">
<UiOiseauPose v-for="o in OISEAUX_CARTE[esp.label] ?? []" :key="`${o.nom}-${o.pose}`" v-bind="o" />
<div class="espace-icon">
<div :class="`i-lucide-${esp.icon} h-4 w-4`" />
</div>
<div>
<p class="font-semibold text-sm" style="color: hsl(var(--color-text))">{{ esp.label }}</p>
<p class="text-xs mt-0.5 leading-relaxed" style="color: hsl(var(--color-text-muted))">{{ esp.desc }}</p>
</div>
</div>
</div>
</div>
<!-- Config -->
<div v-if="evtContent?.config">
<h2 class="evt-section-title relative">
Format & lieu
<!-- S at 72 px, feet on the cards' top edge (the title's 0.25rem margin under it): the
espaces cards above leave 106 px at 768 px, and a perched bird keeps a void of its own
height above it (rule f) -->
<UiOiseauPose nom="regisseur" pose="perche" right="24%" bottom="-0.25rem" :w="72" retourne />
</h2>
<div class="config-grid">
<div v-for="(cfg, i) in evtContent.config" :key="i" class="config-card">
<UiOiseauPose v-for="o in OISEAUX_CARTE[cfg.label] ?? []" :key="`${o.nom}-${o.pose}`" v-bind="o" />
<div class="config-icon">
<div :class="`i-lucide-${cfg.icon} h-4 w-4`" />
</div>
<div>
<p class="font-semibold text-sm" style="color: hsl(var(--color-text))">{{ cfg.label }}</p>
<p class="text-xs mt-0.5" style="color: hsl(var(--color-text-muted))">{{ cfg.detail }}</p>
</div>
</div>
</div>
</div>
</div>
</section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { OiseauPlace } from '~/utils/oiseauPose'
definePageMeta({ definePageMeta({
layout: 'default', layout: 'default',
}) })
const { data: content } = await usePageContent('evenement') const { data: content } = await usePageContent('evenement')
const evtContent = computed(() => content.value as Record<string, any> | null)
useHead({ // Birds standing on the cards, by card label: never two on the same row
title: content.value?.meta?.title ?? 'Évènement', const OISEAUX_CARTE: Record<string, OiseauPlace[]> = {
'Économique': [{ nom: 'jongleur', pose: 'perche', right: '22px', w: 76 }],
'Ğ(marché)': [{ nom: 'cracheur-de-feu', pose: 'perche', right: '18px', w: 76, ton: 'accent', seuil: 1200 }],
}
useSeoPage({
title: evtContent.value?.meta?.title ?? 'Évènement — Le Librodrome',
description: evtContent.value?.description as string ?? 'Prochains événements du Librodrome : rencontres, lectures et ateliers autour de l\'économie du don.',
}) })
</script> </script>
<style scoped> <style scoped>
.evt-format {
margin-top: 0.35rem;
font-weight: 600;
font-size: clamp(1.25rem, 3vw, 1.75rem);
letter-spacing: -0.02em;
color: hsl(var(--axis-cit));
}
.page-title { .page-title {
font-size: clamp(2.5rem, 6vw, 3.5rem); font-size: clamp(2.5rem, 6vw, 3.5rem);
} }
.shadok-juggler { /* ── Logo SVG ── */
position: absolute; .evt-logo-wrap {
left: 4%; width: 4.5rem;
top: 5%; height: 4.5rem;
width: clamp(100px, 14vw, 190px); display: flex;
opacity: 0.3; align-items: center;
pointer-events: none; justify-content: center;
color: hsl(var(--color-primary)); color: hsl(var(--color-primary));
animation: shadok-bounce-juggler 4s ease-in-out infinite; filter: drop-shadow(0 6px 14px hsl(var(--color-primary) / 0.25));
} }
.shadok-ladder { .evt-logo {
position: absolute; height: 100%;
right: 4%; width: auto;
top: 3%; }
width: clamp(90px, 12vw, 170px);
opacity: 0.28; /* ── Badge gestation hero ── */
pointer-events: none; .evt-gestation-badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
background: hsl(var(--color-accent) / 0.12);
border: 1px solid hsl(var(--color-accent) / 0.25);
color: hsl(var(--color-accent)); color: hsl(var(--color-accent));
animation: shadok-wobble-ladder 5s ease-in-out infinite; font-size: 0.75rem;
font-weight: 600;
font-family: var(--font-mono);
letter-spacing: 0.04em;
} }
.shadok-acrobat { /* ── Section contenus événement ── */
position: absolute; .evt-content {
left: 50%; background: hsl(var(--color-bg));
top: 55%; }
transform: translateX(-50%);
width: clamp(100px, 13vw, 180px); .evt-section-title {
opacity: 0.2; font-family: var(--font-display);
pointer-events: none; font-size: clamp(1.25rem, 3vw, 1.75rem);
font-weight: 800;
color: hsl(var(--color-text));
margin-bottom: 0.25rem;
}
.evt-section-sub {
font-family: var(--font-mono);
font-size: 0.8rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: hsl(var(--color-primary)); color: hsl(var(--color-primary));
animation: shadok-spin-acrobat 6s ease-in-out infinite; margin-bottom: 1.5rem;
} }
.shadok-sleeper { /* ── Axes ── */
position: absolute; .axes-grid {
left: 3%; display: grid;
bottom: 5%; gap: 1rem;
width: clamp(110px, 15vw, 210px); grid-template-columns: 1fr;
opacity: 0.25; }
pointer-events: none;
@media (min-width: 640px) {
.axes-grid { grid-template-columns: repeat(3, 1fr); }
}
.axe-card {
position: relative;
padding: 1.5rem;
border-radius: 16px;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-text) / 0.07);
transition: border-color 0.2s, box-shadow 0.2s;
}
.axe-card:hover {
border-color: hsl(var(--color-primary) / 0.25);
box-shadow: 0 4px 24px hsl(var(--color-primary) / 0.06);
}
.axe-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 0.625rem;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
margin-bottom: 1rem;
}
.axe-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.axe-list li {
display: flex;
align-items: flex-start;
gap: 0.5rem;
font-size: 0.82rem;
line-height: 1.5;
color: hsl(var(--color-text-muted));
}
/* ── Espaces ── */
.espaces-grid {
display: grid;
gap: 0.75rem;
grid-template-columns: 1fr;
}
@media (min-width: 640px) {
.espaces-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (min-width: 1024px) {
.espaces-grid { grid-template-columns: repeat(3, 1fr); }
}
.espace-card {
position: relative;
display: flex;
align-items: flex-start;
gap: 0.875rem;
padding: 1rem 1.25rem;
border-radius: 12px;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-text) / 0.06);
transition: border-color 0.15s;
}
.espace-card:hover {
border-color: hsl(var(--color-accent) / 0.25);
}
.espace-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
background: hsl(var(--color-accent) / 0.1);
color: hsl(var(--color-accent)); color: hsl(var(--color-accent));
animation: shadok-float-sleeper 8s ease-in-out infinite; flex-shrink: 0;
} }
.shadok-cook { /* ── Config ── */
position: absolute; .config-grid {
right: 3%; display: grid;
bottom: 4%; gap: 0.75rem;
width: clamp(100px, 14vw, 200px); grid-template-columns: repeat(2, 1fr);
opacity: 0.3; }
pointer-events: none;
@media (min-width: 640px) {
.config-grid { grid-template-columns: repeat(4, 1fr); }
}
.config-card {
position: relative;
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 1rem;
border-radius: 12px;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-text) / 0.06);
}
.config-icon {
display: flex;
align-items: center;
justify-content: center;
width: 1.875rem;
height: 1.875rem;
border-radius: 0.5rem;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary)); color: hsl(var(--color-primary));
animation: shadok-bounce-cook 5s ease-in-out infinite; flex-shrink: 0;
}
@keyframes shadok-bounce-juggler {
0%, 100% { transform: translateY(0); }
30% { transform: translateY(-12px); }
60% { transform: translateY(-6px); }
}
@keyframes shadok-wobble-ladder {
0%, 100% { transform: rotate(0deg); }
25% { transform: rotate(3deg); }
75% { transform: rotate(-3deg); }
}
@keyframes shadok-spin-acrobat {
0% { transform: translateX(-50%) rotate(0deg); }
25% { transform: translateX(-50%) rotate(15deg); }
50% { transform: translateX(-50%) rotate(0deg); }
75% { transform: translateX(-50%) rotate(-15deg); }
100% { transform: translateX(-50%) rotate(0deg); }
}
@keyframes shadok-float-sleeper {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
@keyframes shadok-bounce-cook {
0%, 100% { transform: translateY(0); }
40% { transform: translateY(-10px); }
70% { transform: translateY(-4px); }
}
@media (max-width: 768px) {
.shadok-juggler { display: none; }
.shadok-ladder { display: none; }
.shadok-acrobat { display: none; }
.shadok-sleeper { display: none; }
.shadok-cook { display: none; }
} }
</style> </style>
+6 -65
View File
@@ -1,47 +1,6 @@
<template> <template>
<NuxtLayout> <NuxtLayout>
<div class="relative overflow-hidden section-padding"> <div class="relative overflow-hidden section-padding">
<!-- Shadok alchemist: character stirring a cauldron with sparkles -->
<svg class="shadok-alchemist" viewBox="0 0 240 320" fill="none" aria-hidden="true">
<!-- Body -->
<ellipse cx="120" cy="145" rx="40" ry="48" fill="currentColor" opacity="0.85"/>
<!-- Head -->
<circle cx="120" cy="82" r="24" fill="currentColor" opacity="0.8"/>
<!-- Wizard hat -->
<polygon points="120,30 100,80 140,80" fill="currentColor" opacity="0.5"/>
<line x1="100" y1="80" x2="140" y2="80" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.45"/>
<!-- Hat star -->
<circle cx="118" cy="55" r="3" fill="currentColor" opacity="0.25"/>
<!-- Eyes (mischievous) -->
<circle cx="111" cy="78" r="4.5" fill="currentColor" opacity="0.2"/>
<circle cx="129" cy="78" r="4.5" fill="currentColor" opacity="0.2"/>
<circle cx="112" cy="77" r="2" fill="currentColor" opacity="0.5"/>
<circle cx="130" cy="77" r="2" fill="currentColor" opacity="0.5"/>
<!-- Grin -->
<path d="M112 92 Q120 98 128 92" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.35"/>
<!-- Arm holding spoon/stick -->
<line x1="158" y1="140" x2="175" y2="210" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<!-- Other arm -->
<path d="M82 145 Q65 165 70 185" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" fill="none" opacity="0.6"/>
<!-- Cauldron -->
<path d="M60 220 Q60 260 120 260 Q180 260 180 220" fill="currentColor" opacity="0.4"/>
<ellipse cx="120" cy="220" rx="60" ry="15" fill="currentColor" opacity="0.3"/>
<ellipse cx="120" cy="220" rx="60" ry="15" stroke="currentColor" stroke-width="2" fill="none" opacity="0.4"/>
<!-- Cauldron legs -->
<line x1="80" y1="258" x2="75" y2="280" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.5"/>
<line x1="160" y1="258" x2="165" y2="280" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.5"/>
<!-- Bubbles from cauldron -->
<circle cx="100" cy="210" r="5" fill="currentColor" opacity="0.2"/>
<circle cx="135" cy="205" r="4" fill="currentColor" opacity="0.18"/>
<circle cx="115" cy="198" r="3" fill="currentColor" opacity="0.15"/>
<!-- Sparkles -->
<circle cx="90" cy="190" r="2" fill="currentColor" opacity="0.25"/>
<circle cx="150" cy="195" r="2.5" fill="currentColor" opacity="0.2"/>
<circle cx="105" cy="185" r="1.5" fill="currentColor" opacity="0.2"/>
<!-- Character legs -->
<line x1="105" y1="190" x2="95" y2="225" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.5"/>
<line x1="135" y1="190" x2="145" y2="225" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.5"/>
</svg>
<div class="container-content max-w-3xl mx-auto"> <div class="container-content max-w-3xl mx-auto">
<!-- Back link --> <!-- Back link -->
@@ -58,8 +17,9 @@
<span class="inline-block mb-3 rounded-full bg-amber-400/15 px-3 py-0.5 font-mono text-xs tracking-widest text-amber-400 uppercase"> <span class="inline-block mb-3 rounded-full bg-amber-400/15 px-3 py-0.5 font-mono text-xs tracking-widest text-amber-400 uppercase">
{{ content?.kicker }} {{ content?.kicker }}
</span> </span>
<h1 class="page-title font-display font-bold text-white"> <h1 class="page-title relative font-display font-bold text-white">
{{ content?.title }} <!-- The alchemist at the end of the title, on its baseline (rule e) -->
{{ content?.title }}<UiOiseauPose nom="alchimiste" pose="perche" bout left="16px" :w="98" retourne />
</h1> </h1>
<p class="mt-4 text-lg text-white/60 leading-relaxed max-w-xl mx-auto"> <p class="mt-4 text-lg text-white/60 leading-relaxed max-w-xl mx-auto">
{{ content?.description }} {{ content?.description }}
@@ -104,8 +64,9 @@
<script setup lang="ts"> <script setup lang="ts">
const { data: content } = await usePageContent('gratewizard') const { data: content } = await usePageContent('gratewizard')
useHead({ useSeoPage({
title: content.value?.meta?.title ?? 'grateWizard \u2014 Coefficients relatifs', title: content.value?.meta?.title ?? 'grateWizard — Coefficients relatifs',
description: 'Calculateur de coefficients relatifs pour l\'économie du don et la monnaie libre (DU/June).',
}) })
const { url, launch } = useGrateWizard() const { url, launch } = useGrateWizard()
@@ -136,24 +97,4 @@ code {
border-radius: 0.25em; border-radius: 0.25em;
background: hsl(40 80% 50% / 0.1); background: hsl(40 80% 50% / 0.1);
} }
.shadok-alchemist {
position: absolute;
right: 2%;
top: 10%;
width: clamp(120px, 16vw, 230px);
opacity: 0.3;
pointer-events: none;
color: hsl(var(--color-accent));
animation: shadok-float-alchemist 10s ease-in-out infinite;
}
@keyframes shadok-float-alchemist {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-10px) rotate(1deg); }
}
@media (max-width: 768px) {
.shadok-alchemist { display: none; }
}
</style> </style>
+5 -5
View File
@@ -1,16 +1,16 @@
<template> <template>
<div> <div>
<HomeHeroSection /> <HomeLanding @open-player="showBookPlayer = true" @open-pdf="showPdfReader = true" />
<HomeBookSection @open-player="showBookPlayer = true" @open-pdf="showPdfReader = true" /> <HomeMessages />
<HomeGrateWizardTeaser />
<BookPlayer v-model="showBookPlayer" /> <BookPlayer v-model="showBookPlayer" />
<BookPdfReader v-model="showPdfReader" /> <BookPdfReader v-model="showPdfReader" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
useHead({ useSeoPage({
title: 'Accueil', title: 'Le Librodrome — autonomie numérique, économique, citoyenne',
description: 'Construire une autonomie collective à l\'échelle des bassins de vie. Un livre, des chansons et des outils pour l\'émancipation.',
}) })
const showBookPlayer = ref(false) const showBookPlayer = ref(false)
+83 -9
View File
@@ -2,20 +2,33 @@
<div class="section-padding"> <div class="section-padding">
<div class="container-content mx-auto max-w-3xl"> <div class="container-content mx-auto max-w-3xl">
<h1 class="font-display text-3xl font-bold text-gradient mb-2">Messages des visiteurs</h1> <h1 class="font-display text-3xl font-bold text-gradient mb-2">Messages des visiteurs</h1>
<p class="text-white/50 mb-8">Les mots laissés par celles et ceux qui passent par ici.</p> <p class="page-subtitle mb-8">Les mots laissés par celles et ceux qui passent par ici.</p>
<div v-if="messages?.length" class="space-y-4"> <div v-if="messages?.length" class="space-y-4">
<div v-for="msg in messages" :key="msg.id" class="message-card"> <div v-for="msg in messages" :key="msg.id" class="message-card">
<p class="text-white/80 leading-relaxed">{{ msg.text }}</p> <!-- En-tête auteur -->
<div class="mt-3 flex items-center gap-2 text-xs text-white/40"> <div class="flex items-center gap-2 mb-2">
<span class="font-semibold text-white/60">{{ msg.author }}</span> <span class="msg-author font-semibold text-sm">{{ msg.author }}</span>
<span>&middot;</span> <span class="type-pill">{{ typeLabel(msg.type) }}</span>
<span>{{ formatDate(msg.createdAt) }}</span> <span class="msg-date text-xs ml-auto">{{ formatDate(msg.createdAt) }}</span>
</div>
<!-- Texte du message -->
<p class="msg-text text-sm leading-relaxed">{{ msg.text }}</p>
<!-- Réponse -->
<div v-if="msg.reply?.text" class="reply-thread">
<div class="reply-connector" aria-hidden="true" />
<div class="reply-block">
<div class="flex items-center gap-1.5 mb-1">
<div class="i-lucide-corner-down-right h-3 w-3 reply-icon" />
<span class="reply-author text-xs font-semibold">Le Librodrome</span>
</div>
<p class="reply-text text-sm leading-relaxed italic">{{ msg.reply.text }}</p>
</div>
</div> </div>
</div> </div>
</div> </div>
<p v-else class="text-center text-white/40 py-12">Aucun message pour l'instant.</p> <p v-else class="text-center page-subtitle py-12">Aucun message pour l'instant.</p>
<div class="mt-8 text-center"> <div class="mt-8 text-center">
<NuxtLink to="/" class="btn-ghost text-sm"> <NuxtLink to="/" class="btn-ghost text-sm">
@@ -28,12 +41,24 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
useHead({ useSeoPage({
title: 'Messages', title: 'Messages — Le Librodrome',
description: 'Questions, retours et réactions des lecteurs et visiteurs du Librodrome.',
}) })
const { data: messages } = await useFetch('/api/messages') const { data: messages } = await useFetch('/api/messages')
const TYPE_LABELS: Record<string, string> = {
reaction: 'Réaction',
question: 'Question',
suggestion: 'Suggestion',
retour: 'Retour',
}
function typeLabel(type: string) {
return TYPE_LABELS[type] ?? type
}
function formatDate(iso: string) { function formatDate(iso: string) {
const date = new Date(iso) const date = new Date(iso)
return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' }) return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
@@ -41,10 +66,59 @@ function formatDate(iso: string) {
</script> </script>
<style scoped> <style scoped>
.page-subtitle { color: hsl(var(--color-text) / 0.5); }
.msg-author { color: hsl(var(--color-text) / 0.75); }
.msg-date { color: hsl(var(--color-text) / 0.38); }
.msg-text { color: hsl(var(--color-text) / 0.78); white-space: pre-line; }
.reply-icon { color: hsl(var(--color-primary) / 0.5); }
.reply-author { color: hsl(var(--color-primary) / 0.8); }
.reply-text { color: hsl(var(--color-text) / 0.62); }
.message-card { .message-card {
background: hsl(var(--color-surface)); background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-text) / 0.1); border: 1px solid hsl(var(--color-text) / 0.1);
border-radius: 0.75rem; border-radius: 0.75rem;
padding: 1.25rem 1.5rem; padding: 1.25rem 1.5rem;
} }
.type-pill {
font-size: 0.6rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.1rem 0.45rem;
border-radius: 9999px;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary) / 0.7);
}
.reply-thread {
position: relative;
display: flex;
gap: 0;
margin-top: 0.75rem;
padding-left: 1.25rem;
}
.reply-connector {
position: absolute;
left: 0.5rem;
top: -0.5rem;
bottom: 0.5rem;
width: 2px;
background: linear-gradient(
to bottom,
hsl(var(--color-primary) / 0.35),
hsl(var(--color-primary) / 0.15)
);
border-radius: 2px;
}
.reply-block {
flex: 1;
background: hsl(var(--color-primary) / 0.05);
border-left: 2px solid hsl(var(--color-primary) / 0.25);
border-radius: 0 0.5rem 0.5rem 0;
padding: 0.6rem 0.875rem;
}
</style> </style>
-199
View File
@@ -1,199 +0,0 @@
<template>
<div class="relative overflow-hidden section-padding">
<!-- Shadok reader: character with big glasses reading a book -->
<svg class="shadok-reader" viewBox="0 0 240 300" fill="none" aria-hidden="true">
<!-- Body -->
<ellipse cx="120" cy="170" rx="45" ry="55" fill="currentColor" opacity="0.85"/>
<!-- Head -->
<circle cx="120" cy="100" r="28" fill="currentColor" opacity="0.8"/>
<!-- Big round glasses -->
<circle cx="107" cy="94" r="11" stroke="currentColor" stroke-width="2.5" fill="none" opacity="0.5"/>
<circle cx="133" cy="94" r="11" stroke="currentColor" stroke-width="2.5" fill="none" opacity="0.5"/>
<line x1="118" y1="94" x2="122" y2="94" stroke="currentColor" stroke-width="2" opacity="0.5"/>
<!-- Eyes behind glasses -->
<circle cx="108" cy="93" r="2.5" fill="currentColor" opacity="0.5"/>
<circle cx="134" cy="93" r="2.5" fill="currentColor" opacity="0.5"/>
<!-- Arms holding book -->
<line x1="78" y1="155" x2="60" y2="180" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<line x1="162" y1="155" x2="180" y2="180" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<!-- Book (open) -->
<rect x="55" y="175" width="55" height="40" rx="2" fill="currentColor" opacity="0.35"/>
<rect x="110" y="175" width="55" height="40" rx="2" fill="currentColor" opacity="0.3"/>
<line x1="110" y1="175" x2="110" y2="215" stroke="currentColor" stroke-width="2" opacity="0.5"/>
<!-- Book lines (text) -->
<line x1="65" y1="188" x2="100" y2="188" stroke="currentColor" stroke-width="1" opacity="0.2"/>
<line x1="65" y1="195" x2="95" y2="195" stroke="currentColor" stroke-width="1" opacity="0.2"/>
<line x1="65" y1="202" x2="98" y2="202" stroke="currentColor" stroke-width="1" opacity="0.2"/>
<!-- Legs -->
<line x1="105" y1="222" x2="95" y2="270" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<line x1="135" y1="222" x2="145" y2="270" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
</svg>
<!-- Shadok stack: pile of books tilting -->
<svg class="shadok-stack" viewBox="0 0 160 220" fill="none" aria-hidden="true">
<!-- Bottom book -->
<rect x="20" y="170" width="120" height="22" rx="3" fill="currentColor" opacity="0.5" transform="rotate(-2 80 181)"/>
<!-- Second book -->
<rect x="30" y="145" width="100" height="20" rx="3" fill="currentColor" opacity="0.45" transform="rotate(3 80 155)"/>
<!-- Third book -->
<rect x="25" y="120" width="110" height="18" rx="3" fill="currentColor" opacity="0.4" transform="rotate(-4 80 129)"/>
<!-- Fourth book -->
<rect x="35" y="97" width="90" height="18" rx="3" fill="currentColor" opacity="0.35" transform="rotate(5 80 106)"/>
<!-- Fifth book (tilting more) -->
<rect x="40" y="74" width="80" height="17" rx="3" fill="currentColor" opacity="0.3" transform="rotate(-7 80 82)"/>
<!-- Top book (really tilting) -->
<rect x="45" y="52" width="70" height="16" rx="3" fill="currentColor" opacity="0.25" transform="rotate(10 80 60)"/>
<!-- Tiny Shadok sitting on top -->
<ellipse cx="85" cy="42" rx="10" ry="8" fill="currentColor" opacity="0.5"/>
<circle cx="85" cy="30" r="6" fill="currentColor" opacity="0.45"/>
<circle cx="87" cy="29" r="1.5" fill="currentColor" opacity="0.3"/>
</svg>
<!-- Shadok scribe: character with quill and inkwell -->
<svg class="shadok-scribe" viewBox="0 0 240 280" fill="none" aria-hidden="true">
<ellipse cx="120" cy="155" rx="40" ry="48" fill="currentColor" opacity="0.85"/>
<circle cx="120" cy="92" r="25" fill="currentColor" opacity="0.8"/>
<ellipse cx="120" cy="72" rx="20" ry="8" fill="currentColor" opacity="0.35"/>
<circle cx="112" cy="90" r="4" fill="currentColor" opacity="0.2"/>
<circle cx="130" cy="90" r="4" fill="currentColor" opacity="0.2"/>
<circle cx="113" cy="91" r="1.8" fill="currentColor" opacity="0.5"/>
<circle cx="131" cy="91" r="1.8" fill="currentColor" opacity="0.5"/>
<path d="M118 104 Q120 108 122 104" fill="currentColor" opacity="0.3"/>
<line x1="160" y1="140" x2="190" y2="170" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<line x1="190" y1="170" x2="210" y2="145" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.45"/>
<path d="M210 145 Q215 140 212 135 Q208 138 210 145" fill="currentColor" opacity="0.3"/>
<line x1="80" y1="148" x2="55" y2="180" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<path d="M100 200 Q90 230 95 250" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" fill="none" opacity="0.6"/>
<path d="M140 200 Q150 230 145 250" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" fill="none" opacity="0.6"/>
<rect x="45" y="185" width="20" height="18" rx="3" fill="currentColor" opacity="0.35"/>
<ellipse cx="55" cy="185" rx="12" ry="4" fill="currentColor" opacity="0.3"/>
<rect x="170" y="175" width="40" height="50" rx="2" fill="currentColor" opacity="0.2"/>
<line x1="178" y1="188" x2="202" y2="188" stroke="currentColor" stroke-width="1" opacity="0.15"/>
<line x1="178" y1="195" x2="200" y2="195" stroke="currentColor" stroke-width="1" opacity="0.15"/>
<line x1="178" y1="202" x2="198" y2="202" stroke="currentColor" stroke-width="1" opacity="0.15"/>
</svg>
<div class="container-content">
<header class="mb-12 text-center">
<p class="mb-2 font-mono text-sm tracking-widest text-primary uppercase">{{ content?.kicker }}</p>
<h1 class="page-title font-display font-bold tracking-tight text-white">
{{ content?.title }}
</h1>
<p class="mt-4 mx-auto max-w-2xl text-white/60">
{{ content?.description }}
</p>
</header>
<div class="mx-auto max-w-3xl">
<ul class="flex flex-col gap-3">
<li
v-for="chapter in chapters"
:key="chapter.path"
>
<NuxtLink
:to="`/modele-eco/${chapter.stem?.split('/').pop()}`"
class="card-surface flex items-start gap-4 group"
>
<span class="font-mono text-2xl font-bold text-primary/30 leading-none mt-1 w-10 text-right flex-shrink-0">
{{ String(chapter.order).padStart(2, '0') }}
</span>
<div class="min-w-0 flex-1">
<h2 class="font-display text-lg font-semibold text-white group-hover:text-primary transition-colors">
{{ chapter.title }}
</h2>
<p v-if="chapter.description" class="mt-1 text-sm text-white/50">
{{ chapter.description }}
</p>
<div class="mt-2 flex items-center gap-3">
<span v-if="chapter.readingTime" class="text-xs text-white/30">
<span class="i-lucide-clock inline-block h-3 w-3 mr-1 align-middle" />
{{ chapter.readingTime }}
</span>
<SongBadges :chapter-slug="chapter.stem?.split('/').pop() ?? ''" />
</div>
</div>
<div class="i-lucide-chevron-right h-5 w-5 text-white/20 group-hover:text-primary/60 transition-colors flex-shrink-0 mt-2" />
</NuxtLink>
</li>
</ul>
</div>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'default',
})
const { data: content } = await usePageContent('modele-eco')
useHead({
title: content.value?.meta?.title ?? 'Table des matières',
})
const { data: chapters } = await useAsyncData('book-toc', () =>
queryCollection('book').order('order', 'ASC').all(),
)
</script>
<style scoped>
.page-title {
font-size: clamp(2rem, 5vw, 2.75rem);
}
.shadok-reader {
position: absolute;
right: 2%;
top: 3%;
width: clamp(110px, 15vw, 220px);
opacity: 0.3;
pointer-events: none;
color: hsl(var(--color-primary));
animation: shadok-float-reader 9s ease-in-out infinite;
}
.shadok-stack {
position: absolute;
left: 2%;
bottom: 5%;
width: clamp(100px, 13vw, 180px);
opacity: 0.25;
pointer-events: none;
color: hsl(var(--color-accent));
animation: shadok-float-stack 11s ease-in-out infinite;
}
@keyframes shadok-float-reader {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
@keyframes shadok-float-stack {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-8px) rotate(-2deg); }
}
.shadok-scribe {
position: absolute;
left: 50%;
bottom: 3%;
transform: translateX(-50%);
width: clamp(100px, 13vw, 180px);
opacity: 0.25;
pointer-events: none;
color: hsl(var(--color-primary));
animation: shadok-float-scribe 10s ease-in-out infinite;
}
@keyframes shadok-float-scribe {
0%, 100% { transform: translateX(-50%) translateY(0); }
50% { transform: translateX(-50%) translateY(-8px); }
}
@media (max-width: 768px) {
.shadok-reader { display: none; }
.shadok-stack { display: none; }
.shadok-scribe { display: none; }
}
</style>
File diff suppressed because it is too large Load Diff
+308
View File
@@ -0,0 +1,308 @@
<template>
<div class="axis-scope axis-scope--num oiseaux-scene relative overflow-hidden section-padding">
<div class="container-content">
<header class="mb-12 text-center">
<p class="relative mb-2 font-mono text-sm tracking-widest text-primary uppercase">
<!-- Phone: the title fills the width; the bird stands at the end of this line, on its baseline.
A flat bird (37-44 px tall at 72 px wide): 93 px separate this baseline from the sticky
header, and a perched bird keeps a void of its own height above it (rule f) -->
{{ content?.kicker }}<UiOiseauPose nom="codeuse" pose="perche" bout :w="72" mobile-seul />
</p>
<h1 class="page-title relative font-display font-bold tracking-tight" style="color: hsl(var(--color-text))">
<!-- At the end of the title, on its baseline (rule e, docs/da-oiseaux.md) -->
{{ content?.title }}<UiOiseauPose nom="droneuse" pose="perche" bout left="16px" :w="76" :seuil="1200" />
</h1>
<p class="mt-4 mx-auto max-w-2xl leading-relaxed" style="color: hsl(var(--color-text-muted))">
{{ content?.description }}
</p>
</header>
<div class="mx-auto max-w-3xl flex flex-col gap-6">
<!-- LPK sert l'axe numérique -->
<NuxtLink v-if="lpk" :to="lpk.to" class="lpk-card">
<UiOiseauPose nom="electricien" pose="marge-g" ecart="40px" :w="150" />
<span class="lpk-name">{{ lpk.name }}</span>
<span class="lpk-body">
<span class="lpk-full">{{ lpk.text }}</span>
<span class="lpk-role">{{ lpk.role }}</span>
</span>
<span class="i-lucide-arrow-right h-5 w-5 lpk-arrow" aria-hidden="true" />
</NuxtLink>
<div
v-for="pillar in content?.pillars"
:key="pillar.id"
class="pillar-card"
>
<UiOiseauPose v-for="o in OISEAUX_PILIER[pillar.id] ?? []" :key="`${o.nom}-${o.pose}`" v-bind="o" />
<!-- Overlay lien — toute la carte cliquable -->
<NuxtLink v-if="pillar.to" :to="pillar.to" class="pillar-overlay" :aria-label="pillar.label" tabindex="-1" />
<!-- Corps de la carte -->
<div class="pillar-body">
<div class="pillar-header">
<div class="pillar-icon">
<div :class="`i-lucide-${pillar.icon}`" class="h-5 w-5" />
</div>
<h2 class="font-display text-xl font-bold" style="color: hsl(var(--color-text))">
{{ pillar.label }}
</h2>
<UiStatusBadge class="ml-auto" :status="pillar.status" :gestation="pillar.gestation" :since="pillar.since" />
</div>
<p class="leading-relaxed whitespace-pre-line mt-3" style="color: hsl(var(--color-text-muted))">{{ pillar.text }}</p>
<!-- Project card -->
<div v-if="pillar.project" class="project-card mt-4">
<div class="project-icon">
<div class="i-lucide-rocket h-4 w-4" />
</div>
<div>
<span class="font-display font-semibold text-sm" style="color: hsl(var(--color-text))">{{ pillar.project.name }}</span>
<span class="text-sm ml-2" style="color: hsl(var(--color-text-muted))">{{ pillar.project.text }}</span>
</div>
</div>
</div>
<!-- Zone actions — z-index au-dessus de l'overlay -->
<div v-if="(pillar as any).actions?.length" class="pillar-actions">
<a
v-for="action in (pillar as any).actions"
:key="action.label"
:href="action.href"
target="_blank"
rel="noopener noreferrer"
class="pillar-action-btn"
:class="{ 'pillar-action-btn--highlight': action.highlight }"
>
<div :class="`i-lucide-${action.icon}`" class="h-3.5 w-3.5" />
{{ action.label }}
</a>
<span v-for="action in (pillar as any).actions.filter((a: any) => a.note)" :key="`n-${action.label}`" class="pillar-action-note">{{ action.note }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { OiseauPlace } from '~/utils/oiseauPose'
definePageMeta({
layout: 'default',
})
const { data: content } = await usePageContent('numerique')
// Birds by pillar, each next to the trade it is about (composition of 03/11)
const OISEAUX_PILIER: Record<string, OiseauPlace[]> = {
// The cards stand 16 px apart: no room above them for a perched bird (DA rule of 03/11).
// In the margins, no strict zigzag and several sizes (DA, 03/11 07h56): two stand at the
// bottom of their card on the right, the network admin is large behind Cloud libre
'logiciel-libre': [
{ nom: 'codeuse', pose: 'marge-d', ecart: '28px', w: 84, retourne: true },
],
'authentification-wot': [
{ nom: 'cryptographe', pose: 'marge-d', ecart: '64px', w: 112, retourne: true },
],
'cloud-libre': [
// out by the side of the card, not by its corner: the card is a link, and its rounded corner
// let 5 px of the drawing show on it (rule h)
{ nom: 'admin-reseau', pose: 'derriere', left: '-150px', bottom: '20px', w: 236 },
],
}
const { data: site } = await useSiteContent()
const lpk = computed(() => site.value?.lpk)
useSeoPage({
title: content.value?.meta?.title ?? 'Autonomie numérique',
description: (content.value as Record<string, unknown> | null)?.description as string ?? 'Logiciel libre, authentification Web of Trust, cloud souverain — reprendre le contrôle de ses outils numériques.',
})
</script>
<style scoped>
.lpk-card {
position: relative;
display: flex;
align-items: center;
gap: 1.25rem;
padding: 1.25rem 1.5rem;
border-radius: 16px;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-text));
transition: transform 0.12s ease;
}
.lpk-card:hover { transform: translateY(-3px); }
.lpk-card:active { transform: translateY(0); }
.lpk-name {
font-family: var(--font-display);
font-weight: 800;
font-size: 2rem;
letter-spacing: -0.03em;
color: hsl(var(--color-primary));
}
.lpk-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.lpk-full {
font-family: var(--font-mono);
font-size: 0.72rem;
color: hsl(var(--color-text-muted));
}
.lpk-role {
font-size: 1.02rem;
line-height: 1.45;
}
.pillar-action-note {
align-self: center;
font-family: var(--font-mono);
font-size: 0.7rem;
color: hsl(var(--axis-cit));
}
.lpk-arrow { color: hsl(var(--color-primary)); flex-shrink: 0; }
@media (prefers-reduced-motion: reduce) {
.lpk-card { transition: none; }
}
.page-title {
font-size: clamp(2rem, 5vw, 2.75rem);
}
.pillar-card {
border-radius: 16px;
border: none;
background: hsl(var(--color-surface));
transition: border-color 0.2s;
position: relative;
}
.pillar-card:hover {
border-color: hsl(var(--color-primary) / 0.2);
}
.pillar-overlay {
position: absolute;
inset: 0;
z-index: 1;
border-radius: inherit;
}
.pillar-body {
padding: 1.5rem;
}
.pillar-actions {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
padding: 0.75rem 1.5rem;
border-top: 1px solid hsl(var(--color-text) / 0.06);
background: hsl(var(--color-bg) / 0.4);
border-bottom-left-radius: 0.75rem;
border-bottom-right-radius: 0.75rem;
position: relative;
z-index: 2;
}
.pillar-action-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: 0.5rem;
font-size: 0.8rem;
font-weight: 500;
color: hsl(var(--color-text) / 0.7);
background: hsl(var(--color-text) / 0.05);
border: 1px solid hsl(var(--color-text) / 0.1);
transition: all 0.2s;
text-decoration: none;
cursor: pointer;
}
.pillar-action-btn:hover {
color: hsl(var(--color-text));
background: hsl(var(--color-primary) / 0.12);
border-color: hsl(var(--color-primary) / 0.3);
}
.pillar-action-btn--highlight {
color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.12);
border-color: hsl(var(--color-primary) / 0.25);
}
.pillar-action-btn--highlight:hover {
background: hsl(var(--color-primary) / 0.2);
border-color: hsl(var(--color-primary) / 0.4);
}
.pillar-header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
.pillar-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.5rem;
background: hsl(var(--color-primary) / 0.15);
color: hsl(var(--color-primary));
box-shadow: 0 0 12px hsl(var(--color-primary) / 0.12);
flex-shrink: 0;
}
.gestation-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-left: auto;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
background: hsl(var(--color-accent) / 0.12);
color: hsl(var(--color-accent));
font-size: 0.7rem;
font-weight: 500;
font-family: var(--font-mono);
}
.project-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
background: hsl(var(--color-bg) / 0.5);
border: 1px solid hsl(var(--color-primary) / 0.1);
}
.project-icon {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 0.375rem;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
flex-shrink: 0;
}
</style>
+9
View File
@@ -0,0 +1,9 @@
import type { RouterConfig } from '@nuxt/schema'
export default <RouterConfig>{
scrollBehavior(to, _from, savedPosition) {
if (savedPosition) return savedPosition
if (to.hash) return { el: to.hash, behavior: 'smooth' }
return { top: 0, behavior: 'instant' }
},
}
+51 -24
View File
@@ -8,6 +8,10 @@ interface PaletteColors {
surfaceLight: string surfaceLight: string
text: string text: string
textMuted: string textMuted: string
// One hue per axis: numérique (cool) · économique (warm) · citoyenne (green)
axisNum: string
axisEco: string
axisCit: string
isLight: boolean isLight: boolean
label: string label: string
icon: string icon: string
@@ -25,6 +29,9 @@ const palettes: Record<PaletteName, PaletteColors> = {
surfaceLight: '20 8% 17%', // bois fumé surfaceLight: '20 8% 17%', // bois fumé
text: '0 0% 100%', text: '0 0% 100%',
textMuted: '0 0% 65%', textMuted: '0 0% 65%',
axisNum: '195 55% 60%',
axisEco: '32 85% 55%',
axisCit: '95 35% 55%',
isLight: false, isLight: false,
label: 'Automne', label: 'Automne',
icon: 'i-lucide-leaf', icon: 'i-lucide-leaf',
@@ -39,6 +46,9 @@ const palettes: Record<PaletteName, PaletteColors> = {
surfaceLight: '220 12% 18%', // brume nocturne surfaceLight: '220 12% 18%', // brume nocturne
text: '0 0% 100%', text: '0 0% 100%',
textMuted: '210 10% 65%', textMuted: '210 10% 65%',
axisNum: '205 70% 66%',
axisEco: '36 80% 62%',
axisCit: '160 40% 58%',
isLight: false, isLight: false,
label: 'Hiver', label: 'Hiver',
icon: 'i-lucide-snowflake', icon: 'i-lucide-snowflake',
@@ -55,6 +65,9 @@ const palettes: Record<PaletteName, PaletteColors> = {
surfaceLight: '148 22% 77%', // feuillage vif surfaceLight: '148 22% 77%', // feuillage vif
text: '155 50% 6%', // encre noire-verte text: '155 50% 6%', // encre noire-verte
textMuted: '150 22% 28%', // sous-bois dense textMuted: '150 22% 28%', // sous-bois dense
axisNum: '212 65% 34%',
axisEco: '338 80% 42%',
axisCit: '152 80% 22%',
isLight: true, isLight: true,
label: 'Printemps', label: 'Printemps',
icon: 'i-lucide-flower-2', icon: 'i-lucide-flower-2',
@@ -69,42 +82,56 @@ const palettes: Record<PaletteName, PaletteColors> = {
surfaceLight: '30 32% 78%', // argile chaude surfaceLight: '30 32% 78%', // argile chaude
text: '20 45% 8%', // brun profond text: '20 45% 8%', // brun profond
textMuted: '22 22% 30%', // ombre terracotta textMuted: '22 22% 30%', // ombre terracotta
axisNum: '198 70% 30%',
axisEco: '24 88% 42%',
axisCit: '150 45% 28%',
isLight: true, isLight: true,
label: 'Été', label: 'Été',
icon: 'i-lucide-sun', icon: 'i-lucide-sun',
}, },
} }
export const usePaletteStore = defineStore('palette', () => { // Chaque palette devient un "mood" du layer @yvv/nuxt-base : les couleurs
const currentPalette = ref<PaletteName>( // (identité du projet) sont injectées en variables CSS --color-*, et les classes
(import.meta.client && localStorage.getItem('palette') as PaletteName) || 'automne', // .palette-light / .palette-dark + color-scheme sont gérées par le mécanisme partagé.
) const paletteMoods = (Object.entries(palettes) as [PaletteName, PaletteColors][]).map(
([id, c]) => ({
id,
isDark: !c.isLight,
vars: {
'--color-primary': c.primary,
'--color-accent': c.accent,
'--color-surface': c.surface,
'--color-bg': c.bg,
'--color-surface-light': c.surfaceLight,
'--color-text': c.text,
'--color-text-muted': c.textMuted,
'--axis-num': c.axisNum,
'--axis-eco': c.axisEco,
'--axis-cit': c.axisCit,
},
}),
)
export const usePaletteStore = defineStore('palette', () => {
// Application DOM + persistance + light/dark déléguées au layer partagé.
// storageKey 'palette' conservé pour ne pas perdre les préférences existantes.
const { currentMood, setMood, initMood } = useMood(paletteMoods, {
storageKey: 'palette',
defaultId: 'ete',
syncColorMode: false, // librodrome n'utilise pas @nuxtjs/color-mode
lightDarkClasses: true,
})
const currentPalette = computed<PaletteName>(() => (currentMood.value as PaletteName) || 'ete')
const colors = computed(() => palettes[currentPalette.value]) const colors = computed(() => palettes[currentPalette.value])
const isLight = computed(() => colors.value.isLight) const isLight = computed(() => colors.value.isLight)
function applyToDOM() { // Init depuis localStorage (SSR-safe : no-op côté serveur).
if (!import.meta.client) return const applyToDOM = () => initMood()
const c = colors.value
const root = document.documentElement
const s = root.style
s.setProperty('--color-primary', c.primary)
s.setProperty('--color-accent', c.accent)
s.setProperty('--color-surface', c.surface)
s.setProperty('--color-bg', c.bg)
s.setProperty('--color-surface-light', c.surfaceLight)
s.setProperty('--color-text', c.text)
s.setProperty('--color-text-muted', c.textMuted)
// Toggle light/dark class for CSS overrides
root.classList.toggle('palette-light', c.isLight)
root.classList.toggle('palette-dark', !c.isLight)
s.setProperty('color-scheme', c.isLight ? 'light' : 'dark')
}
function setPalette(name: PaletteName) { function setPalette(name: PaletteName) {
currentPalette.value = name setMood(name)
if (import.meta.client) localStorage.setItem('palette', name)
applyToDOM()
} }
return { return {
+7
View File
@@ -12,13 +12,20 @@ export interface ChapterSongLink {
primary: boolean primary: boolean
} }
export interface ChapterPageLink {
chapterSlug: string
page: number
}
export interface BookConfig { export interface BookConfig {
title: string title: string
author: string author: string
description: string description: string
coverImage?: string coverImage?: string
pdfFile?: string
chapters: ChapterMeta[] chapters: ChapterMeta[]
songs: import('./song').Song[] songs: import('./song').Song[]
chapterSongs: ChapterSongLink[] chapterSongs: ChapterSongLink[]
chapterPages: ChapterPageLink[]
defaultPlaylistOrder: string[] defaultPlaylistOrder: string[]
} }
+18
View File
@@ -0,0 +1,18 @@
// Placement of a bird on its block (props of UiOiseauPose), for the per-page tables.
export interface OiseauPlace {
nom: string
pose?: 'perche' | 'marge-g' | 'marge-d' | 'derriere' | 'libre'
w?: number
left?: string
right?: string
top?: string
bottom?: string
ecart?: string
retourne?: boolean
mobile?: boolean
mobileSeul?: boolean
seuil?: 768 | 1024 | 1200
ton?: 'primary' | 'accent'
op?: number
bout?: boolean
}
+316
View File
@@ -0,0 +1,316 @@
// The 61 birds of the librodrome (studio DA, 2026-10-28) and the footer strip.
// ratio : aspect ratio of the drawing's box ; sol : where its ground line is, in % of the box height
// from the bottom (to stand a bird on an edge of the page : translateY(sol%)).
// Files : /images/oiseaux/<name>.svg
export const OISEAUX: Record<string, { ratio: string, sol: number, univers: string }> = {
"calligraphe": {
"ratio": "152.0 / 116.7",
"sol": 5.0,
"univers": "livre"
},
"cartonnier": {
"ratio": "170.0 / 113.5",
"sol": 5.2,
"univers": "livre"
},
"colporteur": {
"ratio": "152.0 / 119.6",
"sol": 5.1,
"univers": "livre"
},
"conteuse": {
"ratio": "175.3 / 155.6",
"sol": 3.9,
"univers": "livre"
},
"correcteur": {
"ratio": "152.0 / 112.8",
"sol": 5.4,
"univers": "livre"
},
"cycliste-livreur": {
"ratio": "170.0 / 97.6",
"sol": 6.0,
"univers": "livre"
},
"empileur": {
"ratio": "157.0 / 147.4",
"sol": 4.0,
"univers": "livre"
},
"factrice": {
"ratio": "171.8 / 135.8",
"sol": 4.5,
"univers": "livre"
},
"illustratrice": {
"ratio": "164.0 / 129.6",
"sol": 4.6,
"univers": "livre"
},
"lectrice": {
"ratio": "137.0 / 133.5",
"sol": 4.5,
"univers": "livre"
},
"libraire": {
"ratio": "170.0 / 133.6",
"sol": 4.6,
"univers": "livre"
},
"relieur": {
"ratio": "152.0 / 129.6",
"sol": 4.7,
"univers": "livre"
},
"typographe": {
"ratio": "157.0 / 131.6",
"sol": 4.6,
"univers": "livre"
},
"apicultrice": {
"ratio": "167.0 / 155.6",
"sol": 3.9,
"univers": "bassin"
},
"berger": {
"ratio": "172.0 / 133.0",
"sol": 4.4,
"univers": "bassin"
},
"boulangere": {
"ratio": "157.0 / 147.4",
"sol": 4.1,
"univers": "bassin"
},
"forgeron": {
"ratio": "158.0 / 160.7",
"sol": 3.7,
"univers": "bassin"
},
"maraichere": {
"ratio": "164.0 / 112.1",
"sol": 5.4,
"univers": "bassin"
},
"menuisier": {
"ratio": "164.0 / 115.1",
"sol": 5.2,
"univers": "bassin"
},
"pompeur-de-g1": {
"ratio": "159.0 / 115.9",
"sol": 5.1,
"univers": "bassin"
},
"potier": {
"ratio": "157.0 / 90.6",
"sol": 6.5,
"univers": "bassin"
},
"tisserand": {
"ratio": "162.0 / 113.6",
"sol": 5.3,
"univers": "bassin"
},
"vigneronne": {
"ratio": "157.0 / 138.6",
"sol": 4.4,
"univers": "bassin"
},
"admin-reseau": {
"ratio": "137.0 / 138.6",
"sol": 4.3,
"univers": "numerique"
},
"cafeine": {
"ratio": "166.0 / 137.4",
"sol": 4.3,
"univers": "numerique"
},
"canard-en-plastique": {
"ratio": "166.0 / 126.2",
"sol": 4.7,
"univers": "numerique"
},
"codeuse": {
"ratio": "164.0 / 95.6",
"sol": 6.3,
"univers": "numerique"
},
"cryptographe": {
"ratio": "172.9 / 147.5",
"sol": 4.0,
"univers": "numerique"
},
"debogueur": {
"ratio": "166.0 / 111.3",
"sol": 5.3,
"univers": "numerique"
},
"droneuse": {
"ratio": "169.0 / 152.2",
"sol": 3.9,
"univers": "numerique"
},
"electricien": {
"ratio": "174.0 / 152.8",
"sol": 4.0,
"univers": "numerique"
},
"imprimeuse-3d": {
"ratio": "166.0 / 146.0",
"sol": 4.1,
"univers": "numerique"
},
"le-manchot": {
"ratio": "166.0 / 109.6",
"sol": 5.4,
"univers": "numerique"
},
"porteur-de-serveur": {
"ratio": "137.0 / 140.5",
"sol": 4.3,
"univers": "numerique"
},
"soudeuse": {
"ratio": "166.0 / 114.6",
"sol": 5.1,
"univers": "numerique"
},
"toile-de-confiance": {
"ratio": "137.3 / 159.8",
"sol": 3.8,
"univers": "numerique"
},
"avocate": {
"ratio": "137.0 / 141.6",
"sol": 4.3,
"univers": "cite"
},
"capitaine": {
"ratio": "157.0 / 116.5",
"sol": 5.1,
"univers": "cite"
},
"cartographe": {
"ratio": "151.0 / 123.6",
"sol": 4.9,
"univers": "cite"
},
"comedien": {
"ratio": "137.0 / 150.0",
"sol": 3.9,
"univers": "cite"
},
"juge": {
"ratio": "137.0 / 154.9",
"sol": 3.9,
"univers": "cite"
},
"matelot": {
"ratio": "172.0 / 115.7",
"sol": 5.1,
"univers": "cite"
},
"mime": {
"ratio": "137.0 / 112.8",
"sol": 5.3,
"univers": "cite"
},
"vigie": {
"ratio": "184.0 / 154.0",
"sol": 3.9,
"univers": "cite"
},
"accordeoniste": {
"ratio": "146.3 / 135.6",
"sol": 4.5,
"univers": "scene"
},
"acrobate": {
"ratio": "137.0 / 153.4",
"sol": 3.9,
"univers": "scene"
},
"batteur": {
"ratio": "164.0 / 153.6",
"sol": 3.9,
"univers": "scene"
},
"clown": {
"ratio": "159.4 / 127.9",
"sol": 4.7,
"univers": "scene"
},
"cracheur-de-feu": {
"ratio": "137.0 / 141.0",
"sol": 4.2,
"univers": "scene"
},
"funambule": {
"ratio": "170.0 / 162.9",
"sol": 3.7,
"univers": "scene"
},
"jongleur": {
"ratio": "137.0 / 148.3",
"sol": 4.0,
"univers": "scene"
},
"marionnettiste": {
"ratio": "164.0 / 133.6",
"sol": 4.5,
"univers": "scene"
},
"regisseur": {
"ratio": "168.0 / 127.9",
"sol": 4.7,
"univers": "scene"
},
"trapeziste": {
"ratio": "137.0 / 163.6",
"sol": 3.7,
"univers": "scene"
},
"alchimiste": {
"ratio": "164.0 / 133.2",
"sol": 4.6,
"univers": "musique"
},
"danseur": {
"ratio": "162.0 / 159.6",
"sol": 3.8,
"univers": "musique"
},
"dj": {
"ratio": "164.0 / 155.6",
"sol": 3.8,
"univers": "musique"
},
"la-balance": {
"ratio": "170.3 / 150.4",
"sol": 3.9,
"univers": "musique"
},
"penseur": {
"ratio": "162.0 / 150.9",
"sol": 3.9,
"univers": "musique"
},
"philosophe": {
"ratio": "162.0 / 155.2",
"sol": 3.8,
"univers": "musique"
},
"trompettiste": {
"ratio": "164.0 / 158.1",
"sol": 3.7,
"univers": "musique"
},
"frise": {
"ratio": "400 / 80",
"sol": 6.3,
"univers": "pied de page"
}
}
+1
View File
@@ -9,6 +9,7 @@ export default defineContentConfig({
title: z.string(), title: z.string(),
description: z.string().optional(), description: z.string().optional(),
order: z.number(), order: z.number(),
page: z.number().optional(),
readingTime: z.string().optional(), readingTime: z.string().optional(),
}), }),
}), }),
+30 -18
View File
@@ -1,37 +1,49 @@
--- ---
title: "Introduction" title: "Introduction"
description: "Un livre et des chansons pour réaliser une économie du don, portée par les monnaie-libristes." description: "La proposition d'un modèle économique, permis par la monnaie libre — une expérimentation concrète, économique et civile."
order: 1 order: 1
page: 9
readingTime: "15 min" readingTime: "15 min"
--- ---
## Ce livre est une façon ## Introduction
Ce livre est un essai. Une proposition. Une intention. Une invitation. Une façon. [ *masc.* ]
Il ne s'agit pas d'une théorie universelle, mais d'une **expérimentation concrète, économique, civile et artistique**. Pas un guide, pas un kit : plutôt un *git* — un dépôt de réflexions ouvertes, une cartographie et quelques boussoles dans une jungle à défricher. Ce livre est un essai : la proposition d'un modèle économique, permis par la monnaie libre. Je désigne par là une seule et unique devise, dénommée June, qui applique la Théorie Relative de la Monnaie – TRM.
## À qui s'adresse ce livre ? Ce modèle n'est pas une théorie universelle. Je vous fais la proposition d'une expérimentation concrète, économique et civile. Une expérimentation qui peut démarrer sans condition, même à l'échelle d'un petit nombre. Mais c'est en passant à l'échelle d'un bassin de vie qu'elle change de portée et devient… économique.
Ce livre s'adresse aux quelque **8 500 monnaie-libristes** (au moment de l'écriture) qui utilisent la June (Ğ1), monnaie libre fondée sur la Théorie Relative de la Monnaie. L'ambition : proposer un **modèle économique** concret pour passer de l'expérimentation monétaire à une véritable économie du don. Ce livre n'est donc pas un guide, encore moins un kit. Il ne vous dira pas quoi faire lundi. Il propose des repères concrets et conceptuels pour nourrir une réflexion collective. Le passage à l'action vous appartient.
## Pourquoi une économie du don ? L'expérience est destinée en premier lieu aux « monnaie-libristes » — quelque 8.500 créateurs monétaires de DUĞ1 à l'heure où j'écris ces lignes. Mais je m'adresse également à toute personne intriguée par le don avec un regard anthropologique ou économique ; aux personnes affranchies qui peuvent consacrer du temps et des savoir-faire ; à celles qui cherchent à créer un monde alternatif ; aux jeunes adultes qui ont la possibilité de faire des choix de vie.
Nous avons déjà une économie. Elle couvre nos besoins vitaux, de facto. Alors pourquoi en créer une autre ? > *Or le pouvoir de la création monétaire est avant tout celui de créer une économie. Une monnaie n'a aucun lieu d'être sans une économie à servir.*
Pour **l'autonomie**. Et c'est tout sauf un repli. Réfuter l'autonomie, c'est nous bannir en tant qu'adultes bien vivants. L'autonomie, ici, c'est la capacité d'un groupe humain à couvrir ses besoins essentiels par ses propres moyens — sans pour autant se couper du reste du monde. > *Il n'est ici question que d'une économie du don. Rien d'autre.*
## Ce que ce livre n'est pas ---
- Ce n'est pas une baguette magique ## Monnaie-libristes
- Ce n'est pas un programme politique
- Ça ne dit pas quoi faire lundi
- Ce n'est pas du prêt-à-porter : à nous de tailler
## Ce que ce livre est [ *fém.* ]
Un **os à ronger**. Une cartographie avec quelques boussoles. Un livre *et* des chansons, parce que la musique porte les idées autrement. Une partie des junistes ont la volonté d'engendrer une économie monnaie-libriste ; une autre y voit un moyen de rectifier notre économie euro ; d'autres encore y voient un jeu. Les portes d'entrée sont multiples : le logiciel libre, la blockchain et la Toile de Confiance, la sémantique du « dividende universel », les monnaies locales.
Chaque chapitre est accompagné d'un morceau qui en prolonge le propos. Le livre se lit, s'écoute, se vit. Pour témoigner de mon propre cheminement, je suis arrivé sur la june depuis l'étude de l'économie, menant à la création monétaire, en passant par la case Bernard Lietaer. Donc par la porte de la TRM, moins fréquentée.
> *On tourne une page pour voir ?* Beaucoup d'énergie est mise sur la tentative de « convertir » des commerçantes dans une démarche très prosélyte, vivant la monnaie libre comme une monnaie locale. C'est naturel — on reproduit des schémas familiers. Mais rester là-dessus ne crée pas une économie.
> *Car l'économie – avant l'échange – c'est la production.*
J'utilise l'image de la première vitesse et du geste de « passer la seconde ». Rester en première, c'est énormément d'énergie pour avancer au pas.
Il manque à beaucoup d'endroits la notion de finalité. Changer de devise n'est pas suffisant. Chaque groupe peut définir sa propre finalité — il en faut une. La monnaie libre n'est pas une baguette magique.
> *Le leitmotiv de l'autonomie collective à échelle humaine est un excellent candidat, en tant que finalité.*
L'autonomie n'est pas un repli sur soi, c'est le premier leitmotiv des parents envers leurs enfants. L'année 2026, où je reprends cet essai, s'annonce comme une année de défis. J'appelle de mes vœux la démarche collective de moins subir les agendas.
L'intention ici est de proposer un modèle, comme un os à ronger — quelques repères économiques, administratifs et symboliques. Progresser sur un terrain vierge nécessite de tâtonner et, dans notre cas, de la créativité.
> *Ce n'est pas encore l'heure du « prêt-à-porter ».*
+66 -21
View File
@@ -1,42 +1,87 @@
--- ---
title: "De quel don parlons-nous ?" title: "De quel don parlons-nous ?"
description: "Du don de Marcel Mauss aux asymétries du quotidien : comprendre le triple geste donner-recevoir-rendre." description: "Le don, en tant que vecteur clé d'une économie, est le geste de transmettre quelque chose en premier lieu, sans réciprocité symétrique, mais assuré d'un contre-don."
order: 2 order: 2
readingTime: "20 min" page: 23
readingTime: "18 min"
--- ---
## L'oxymore apparent Dans le titre, c'est évidemment le terme « don » qui marque le plus, car il a tendance à s'opposer au terme économie — une sorte d'oxymore. Le don flirte parfois avec la spiritualité, ce qui prend le dessus sur une notion que l'on se représente froide et intellectuelle. Mais l'est-elle vraiment ?
« Économie du don » — le mariage fait peur. Un oxymore pour l'esprit. Pour éviter tout quiproquo : il ne s'agit ni de spirituel, ni de karma, ni du « centuple divin ». Ici, le don est un **geste opératoire**, une base, une fondation pour une autre forme de construction. ## Trois mots de philo, et de socio
## Marcel Mauss et le triple geste *[ fém. ]*
Ce n'est pas l'image d'Épinal du don gratuit. **Marcel Mauss**, dans son *Essai sur le don* (1925), a montré que le don est un cycle vital en trois temps : Je commence par le don, en évacuant le registre spirituel : je ne parle pas du don religieux, du centuple divin, ni de celui que « l'univers me rendra ». Je parle seulement du geste.
1. **Donner** — l'initiative, le geste premier Marcel Mauss est souvent cité dès que l'on parle du don. À l'occasion d'une boucle bretonne, j'ai pu observer que les présentes personnes avaient une interprétation un peu fantasmée des pratiques du don dans les temps anciens — y compris les lectrices de Mauss, alors qu'il présente une tension souvent redoutable : une dimension prépondérante des rapports de domination ; une décorrélation marquée avec les échanges de subsistance.
2. **Recevoir** — accepter crée un lien, une obligation
3. **Rendre** — boucler le cycle, entretenir la relation
C'est un pacte, une tension, parfois même un combat. Si tu enfreins le protocole... ça ne pardonne pas. Notre héritage judéo-chrétien associe culturellement le don à quelque chose qui ne peut – ne doit – pas être rendu. Mais avant les évangiles, le don était un geste triple indissociable : donner — recevoir — rendre (contre-don).
## La monnaie-dette n'est pas la monnaie J'ai entendu aussi l'idée qu'il faudrait pratiquer le troc, ou supprimer la monnaie comme finalité ultime. Ce propos repose sur l'idée que la monnaie est en elle-même porteuse de problèmes. Pourtant, c'est seulement la monnaie-dette qui génère ces déséquilibres. L'existence d'une création monétaire décentralisée change la donne. La promesse d'une économie du don, c'est que cette monnaie (produite par un DU selon la TRM) apparaît alors davantage comme un outil qui permet de créer des équilibres.
On entend souvent : « Brûlons la monnaie ! Le troc, la gratuité, le grand projet... » La monnaie serait le vice, la source de tout le mal. **Erreur de cible.** Ce n'est pas la monnaie le problème, c'est la *dette*. La monnaie-dette, celle qui nous tient, celle qui nous guette. Mais si la monnaie est libre ? Elle permet les équilibres. Pour finir, il y a l'idée que le don ne peut pas être mesuré. Je conclus ce chapitre en positionnant clairement le don dont je parle — non pas une définition universelle, mais une définition dans le cadre d'un modèle éco monnaie-libriste :
## Les asymétries > Le don, en tant que vecteur clé d'une économie, est le **geste de transmettre quelque chose**
>
> en premier lieu ;
> sans réciprocité symétrique ;
> mais assuré d'un contre-don ;
> de toute forme, de toute provenance.
>
> **« Mon économie me le rendra »**
>
> → J'y trouve ma pleine mesure.
> → J'y réalise mes propres équilibres.
>
> Le don que nous manipulons est donc : **un don qui se mesure**.
>
> Une mesure qui se fait par l'acte d'affecter un DU (*fractions et multiples de DU*), au geste vécu du don ; **selon ses échelles du moment, individuelles et collectives**.
>
> Une mesure décentralisée et relativiste, pour des milliards d'estimations, toutes légitimes.
Rien n'est symétrique. Ce n'est pas magique. Une pomme aujourd'hui n'est pas la même demain. Une cagette, ce n'est pas cinq palettes. Six heures assis à parler bien à l'aise... six heures à genoux sur un toit — est-ce le même geste ? « Faire du DU la mesure du don », c'est davantage que « faire de la Ğ1 la monnaie du don ». Le DU a une valeur d'invariant qui permet de positionner toutes les valeurs relativement à lui. C'est la raison pour laquelle j'invite à rebaptiser les choses. Parmi ces propositions, la « monnaie » devient la « mesure ».
Les asymétries sont partout. On ne peut pas les supprimer, mais on peut les **mesurer**. Et c'est précisément ce que fait une monnaie bien conçue. ## Asymétries et Communautés
## Communautés et protocoles *[ masc. ]*
Les collectifs qui prônent le don sans outil de mesure finissent souvent par l'usure, le ressentiment, l'abandon. Légiférer, prescrire un comportement ? C'est tentant, mais bancal. Les mouvements alternatifs ont tendance à réunir les « déjà convaincus » et génèrent naturellement une forme d'entre-soi. Les collectifs deviennent souvent des communautés « pseudo-isolées ».
Mieux vaut un **protocole** — une façon de traiter les asymétries, d'éviter de juger, de préfigurer. Sans trancher les sorts à l'insu des participants. Pour cadrer mon raisonnement : sur un collectif récent qui porte le don dans son code génétique, j'y ai recueilli beaucoup de témoignages d'usure, de sentiments d'abus, d'abandons, de ressentiments. Pourquoi ? Parce que dans la totalité de nos échanges, il y a des asymétries.
## Eco si nuestra Nos échanges sont asymétriques dans la totalité des cas. Donner une journée par mois pendant un an, est-ce le même geste que donner deux semaines complètes ? Entre une journée assis à étiqueter des pots et une journée plié en deux sur une toiture, y a-t-il équilibre ? Rien n'est symétrique. Pour éviter les abus ou les déséquilibres, il suffit de se mettre d'accord sur une mesure qui fasse sens pour tous.
L'expérience « Made in Ğ1 » et le jeu **321DU** montrent qu'il est possible de simuler et de vivre une micro-économie du don. Ces prototypages en communauté pseudo-isolée révèlent les mécanismes, les biais, les opportunités. Une population ouverte peut difficilement légiférer sur ces asymétries permanentes ; elle peut en revanche utiliser une monnaie. C'est en cela un outil précieux, probablement le seul.
> *Pour se rétablir, retomber sur nos pieds, il existe un outil qui s'appelle... « la monnaie ».* > *Résoudre le problème des asymétries est le rôle pivot, structurel, que joue la monnaie dans l'échange.*
Si je raisonne à l'échelle de mon bassin de vie, que je donne une valeur aux dons des autres et que les autres donnent une valeur à mes dons, peu importe si quelqu'un me déçoit — je m'y retrouverai dans l'économie de mon bassin de vie. Plus besoin que tout le monde se comporte selon mes critères propres.
En commençant par retrouver dans économie le sens de « *éco - oikos* », son lieu, foyer et terre élue. « Nomos », c'est originellement la façon de distribuer, puis désigne ce que l'être humain institue. Actualisé, on peut lire dans « créer une économie » le fait de définir notre façon de :
> couvrir nos besoins pour vivre
> et
> nourrir nos plaisirs de vivre.
Rien n'empêche de pratiquer un don non mesuré, par exemple pour la beauté de l'élan. Chacun définit son périmètre et ses moments. Mais dès que le don sera mesuré, il deviendra structurant pour l'économie qui émerge.
## Le cas emblématique « eco si nuestra »
*[ fém. ]*
Eco si nuestra est bien connue des junistes. On peut entendre certaines participantes déclarer vivre avec la monnaie-libre pour un tiers de tous leurs besoins. Le premier point est d'associer le DU créé à un devoir et à une durée de travail : le DU que tu crées te rend redevable d'une heure de boulot à la communauté. Elles ont par ailleurs une concertation collective sur les « prix » pour les maintenir au plus bas, et font une double corrélation avec l'heure de travail et les calories.
Une communauté ouverte sur le monde, dont le caractère pseudo-isolé s'est néanmoins révélé lors de rencontres internationales : un gros décalage de pratiques sur les « prix » a généré ce que je baptiserais pudiquement « *les larmes du pot de confiture* ».
Cela invite chaque groupe qui voudra utiliser le DU à une réflexion sur l'interopérabilité de ses règles. Quels sont nos indicateurs relatifs locaux ?
## L'expérience « made in zion »
*[ masc. ]*
Cette expérience de vie communautaire en forêt utilisait les DUs pour structurer le fonctionnement quotidien. Pour vivre : 3 DUs pour les visiteuses, 2 pour les contributrices à l'entretien et 1 seul DU pour les amélioratrices (projets). Une rétribution des tâches par tirage dans une « boîte de gratitudes » évite les débats sur la valeur de chacune. L'effet recherché est de « gamifier » — transformer en jeu.
[ Règles complètes du jeu « 321…DU », @Qoop : https://pad.p2p.legal/s/321DU.LeJeu# ]
Ce genre de règles ne peut être que circonscrit à un lieu partagé, mais cela peut donner des idées pour un grand nombre de contextes.
+34 -16
View File
@@ -1,34 +1,52 @@
--- ---
title: "La mesure du don" title: "La mesure du don"
description: "Le DU comme unité de mesure du don : un retournement sémantique qui change tout." description: "Considérer le DU de Ğ1 comme unité de mesure du don — une façon très factuelle de prendre la mesure du don."
order: 3 order: 3
page: 43
readingTime: "8 min" readingTime: "8 min"
--- ---
## Une mesure, pas un prix ## La mesure du don
Le mot « mesure » a une portée symbolique décisive. Mesurer le don, ce n'est pas le dénaturer ni le marchandiser. C'est lui donner un **référentiel commun** pour que chacun puisse s'y retrouver. *[ fém. ]*
Le **Dividende Universel (DU)** — la quantité de monnaie libre créée chaque jour pour chaque membre — devient l'unité de mesure naturelle. Un DU, c'est la part quotidienne de création monétaire d'un être humain. Ni plus, ni moins. Ce terme « mesure » a un caractère très fonctionnel, mais également une portée symbolique importante. Il s'agit de ne plus considérer la june (Ğ1) comme une monnaie au sens courant du terme, mais de considérer le DU de Ğ1 comme unité de mesure du don.
## Le retournement sémantique Une façon très factuelle de *prendre la mesure* du don ;-)
Voici le basculement fondamental que propose l'économie du don en monnaie libre : La mesure du don, de même que la notion de contre-don, n'est pas une négation du don — c'est une solution très élégante pour gérer les asymétries, éviter les ressentiments qui naissent tôt ou tard, car ces asymétries sont inévitables et permanentes.
- **« Je ne vends plus, je donne »** — mon geste premier est un don, pas une transaction marchande La posture « *je ne veux rien en retour* », de facto, *oblige* l'autre. Elle peut même mettre dans l'embarras : celle qui reçoit ne sait pas quel est le protocole convenable. Ici, le don n'oblige pas. Le protocole est clair — il suffit de mesurer la valeur que j'attribue à ce don, mon degré de gratitude, avec le DU comme unité.
- **« Je n'achète plus, je reçois »** — recevoir engage, crée un lien
- **« Je ne paye plus, je mesure »** — la monnaie libre mesure la gratitude, pas le prix
Ce retournement n'est pas qu'un jeu de mots. Il transforme la posture intérieure de chaque participant à l'échange. Pour bien saisir la portée de ce geste devenant structurant : je donne avant tout à mon économie, à mon bassin de vie. Si j'apprécie la personne en face, j'y mets une dimension plus affective. Mais si je ne l'apprécie pas, non seulement je peux quand même donner — mais c'est surtout désirable, car c'est mon économie, tout mon bassin de vie, que je sers.
## Trans·action ## Retournement sémantique
Le mot « transaction » retrouve son sens premier : **trans** (à travers) + **action**. C'est une action qui traverse, qui relie. Pas un échange froid entre deux intérêts, mais un geste qui crée du lien. *[ masc. ]*
En monnaie libre, chaque transaction est une **mesure de gratitude**. Combien de DU vaut ce que tu m'as donné ? C'est toi qui le dis. C'est moi qui accepte. Et la monnaie, symétrique dans sa création, garantit que personne n'est structurellement avantagé. À compter du moment où l'on ne considère plus le DU comme une monnaie, et que l'on considère tout geste dans le sens du don, on peut naturellement changer de vocabulaire.
## Mesurer sans juger *Je ne vends plus, je donne.*
Le protocole de mesure remplace le besoin de légiférer. Pas besoin d'écrire des règles pour chaque situation : la mesure en DU est un **langage commun** qui permet aux asymétries de s'exprimer et de se résorber naturellement. Ma production, mon temps. Je redonne aussitôt à « l'offre » un sens plein. C'est *mon offre*.
> *Il suffit d'une mesure. Sans morsure. Qui fait sens pour toi et moi, pour tout le monde.* *Je n'achète plus, je reçois.*
Des biens, des services. Je ne crains plus une dépossession monétaire, je valorise la « joie de recevoir ».
*Je ne paye plus, je mesure.*
Je donne du poids, de la masse, des degrés — à la gratitude que je souhaite exprimer, que je souhaite maintenant imprimer dans un registre partagé distribué. En transférant un DU, *j'investis sur un don*, un geste que je souhaite voir se développer dans mon économie.
*On ne négocie plus un prix, on ajuste une balance.*
*On construit nos échelles de valeurs individuelles.*
Et on les frotte à celles des autres. On construit par le même temps des échelles de valeurs collectives.
*On cherche des équilibres, ensemble.*
---
Bien sûr il y a des contextes où l'on n'a pas le temps de jouer à ce jeu des équilibres — par exemple lorsqu'il y a une notion de débit ou une queue. C'est simple : le don se situe en amont. Une buvette a fait ses estimations internes d'équilibre, elle trouve une valeur de référence pour le verre servi. Le don consiste à offrir toute la buvette elle-même. Rien ne m'empêche de gratifier davantage.
On relativise, autour de notre invariant, cadeau de la TRM, le DU.
+107 -16
View File
@@ -1,36 +1,127 @@
--- ---
title: "Raison d'être d'une monnaie" title: "Raison d'être d'une monnaie"
description: "Pourquoi la monnaie est la clé de voûte de toute économie — et pourquoi les monnaies-dette sont génétiquement viciées." description: "Voyons à quel point les modalités de création de chaque unité de monnaie sont déterminantes, comme un code génétique, pour l'économie que cette monnaie servira."
order: 4 order: 4
readingTime: "25 min" page: 49
readingTime: "22 min"
--- ---
## La clé de voûte [ *fém.* ]
La monnaie est l'outil le plus puissant et le plus sous-estimé de toute économie. C'est elle qui, en permanence, résout les asymétries. Même sans qu'on y pense. Chaque monnaie **programme sa propre engeance** — son pouvoir est immense. Je ne vais pas faire un cours sur la monnaie, ni sur son histoire, mais l'évocation d'une absence de monnaie pose tout de même la question de sa nécessité, de son rôle structurant. Elle pose également la question du besoin d'une monnaie radicalement différente. Différente de l'euro, et plus généralement de ce qu'on appelle les « fiat ». Fiat n'est pas un acronyme, c'est le mot latin qui fait référence à quelque chose qui n'existe que par le fait d'avoir été déclaré comme tel. Fiat lux : que la lumière soit. Fiat euro : que l'euro soit.
## L'équation de Fischer Autrement dit, avec une question simple :
**E = Q × V** — l'équation de Fischer pose les bases : la masse monétaire en circulation (E) est le produit de la quantité de monnaie (Q) par sa vitesse de circulation (V). Cette relation simple révèle pourquoi le contrôle de la création monétaire est un enjeu de pouvoir fondamental. - Pourquoi se compliquer la vie avec une autre monnaie ?
## Trois leçons sur les monnaies-dette ## Au-delà de ses 3 fonctions
### 1. La cavalerie [ *masc.* ]
La monnaie-dette fonctionne comme une cavalerie : pour rembourser la dette d'hier, il faut créer de la dette aujourd'hui. Le système ne tient que par la croissance perpétuelle de l'endettement. Quand la musique s'arrête, tout s'effondre. Dans une image architecturale dont les fondations seraient les institutions bancaires, la monnaie et plus précisément la création monétaire, devient la clé de voûte de l'édifice. Tout ce que l'on construit au sein de cette structure et tout ce que l'on vit dans l'édifice, notre économie, repose sur ces institutions bancaires et tient sous l'égide de la création monétaire.
### 2. La machine à faillites La création monétaire est programmatique, comme une ligne de code. Elle détermine certains comportements de toutes les entités économiques chaque jour. Dès que j'achète ou que je vends, dès que j'estime mentalement une valeur, aussitôt je m'inscris dans cette programmatique, dans son référentiel.
Dans un système où la monnaie est créée par le crédit, les intérêts ne sont jamais créés. Il y a donc **structurellement moins de monnaie en circulation que de dettes à rembourser**. Le système fabrique mécaniquement des perdants. Les fameuses trois fonctions formulées par Aristote :
### 3. La pyramide arbitraire - unité de compte : c'est la mesure.
- valeur d'échange : c'est la transaction (apporte la symétrie).
- réserve de valeur : c'est l'épargne.
Qui décide de la création monétaire ? Les banques. Et pour qui ? Pour ceux qui offrent des garanties. Le système monétaire conventionnel est une **pyramide d'accès arbitraire** à la monnaie : ceux qui sont proches de la source en bénéficient, les autres subissent la dilution. La fonction d'unité de compte est plus importante qu'il n'y paraît. C'est parce qu'elle est unité de compte qu'elle peut être une valeur d'échange, et c'est en tant que valeur d'échange qu'il devient indispensable d'en posséder. Si je pense à une cafetière économiquement, je pense un chiffre en euros. Je ne me demande pas si je trouve juste cette équivalence entre un plein d'essence et une cafetière ; … l'est-elle ?
Dans notre économie monnaie-dette, l'usage de réserve de valeur a pris l'ascendant sur les échanges. L'équation de Fischer : E = Q x V. La quantité monétaire multipliée par sa vitesse de circulation mesure l'économie. Si la monnaie est davantage stockée pour faire des réserves, l'économie ralentit. Mais cette formule ne définit pas l'économie elle-même ; elle dévoile en creux à quel point l'économie est d'abord une affaire de production.
Petit message aux monnaie-libristes : il ne suffit pas de faire circuler la monnaie pour se réjouir de son économie. L'économie commence avec une production distribuée.
Je finirai en rappelant que la création monétaire est un pouvoir majeur, ceux qui le détiennent déterminent les échelles de valeur. Donner le même accès à la création monétaire pour tout le monde sans aucune distinction, est bien sûr un cadeau du DU. Mais ce n'est pas la monnaie, toute libre soit-elle, qui crée de l'égalité dans le monde réel.
> Voyons à quel point les modalités de création de chaque unité de monnaie sont déterminantes, comme un code génétique, pour l'économie que cette monnaie servira. Ou asservira, ce qui est devenu le cas de la « nôtre ».
## Bassin économique
[ *fém.* ]
> « à chaque monnaie son économie ».
Si on se mobilise sur le développement d'une monnaie aux modalités de création propres, c'est justement parce qu'elle permet de créer une autre économie. Et si on se mobilise c'est parce que le code monétaire que nous vivons actuellement, celui de notre euro, pose problème. Gros problème. Or rien ne peut y remédier, c'est un code… génétique.
Les monnaies locales ou complémentaires reposant sur une indexation euro, ne permettent pas vraiment de créer une économie différente. Les études économiques sur ces monnaies sont souvent biaisées car endogènes. Ces monnaies créent une sorte de « club », comparable aux « cartes réseaux ». Tous ces collectifs sont précieux car ils s'intéressent à la question monétaire, mais si l'on cherche un impact structurant, il est nécessaire de rompre à la racine avec le code monétaire des fiat.
## Problèmes génétiques des fiat
[ *masc.* ]
Voici trois problèmes majeurs non solvables, de nature strictement monétaire, programmés par le code de création de notre cher euro.
La quasi-totalité (>90%) de la monnaie qui circule est produite par ladite dette. Vous souscrivez un crédit de 100 k€, la banque crée 100.000 unités dont vous pouvez disposer. Les 100.000 unités n'existaient pas, elles sont créées ; puis détruites quand vous les rendez. Nous devons rendre cette quantité de monnaie, mais nous-mêmes ne la créons pas (les banques ont le droit, pas nous) ; nous devons donc capter des unités qui circulent, en produisant une valeur non encore produite.
## Leçon #1 — La monnaie-dette est une cavalerie
Dans ce référentiel monétaire, la croissance n'est pas une option.
[ *fém.* ]
Produire pour rendre la même quantité de monnaie ?
Non. Il faut ajouter lesdits intérêts aux 100 k. 3% sur 10 ans, ça fait près de 30 k, 30 %. Or ces 30.000 unités supplémentaires requises n'ont pas été créées au moment du crédit. Vous devez capter des unités à un autre, qui doit aussi le faire, qui lui aussi doit capter 30 % qui n'ont jamais été créés. Pensez-vous que tout le monde puisse gagner sa partie avec une telle règle du jeu ? Une véritable chaise musicale, où il manquerait non pas une mais le quart des chaises ?
## Leçon #2 — C'est une machine à faillites
Dans ce référentiel monétaire, la compétition, pour ne pas dire une féroce prédation, n'est pas une option.
[ *masc.* ]
Imaginez une population totale de 50 « personnes ». La monnaie créée M est de 5 millions d'unités circulantes. Mais elle doit reverser collectivement 6,5 M€. Où trouve-t-elle le 1,5 million d'unités qui n'existent pas ?
38 « personnes » sur 50 peuvent rembourser leurs 130 k, soit 3,8 millions détruits et 1,14 million captés par la banque. Il reste 60 k€ dans l'économie des 50 personnes, mais 95% dans les caisses de la banque ! 12 personnes en faillite monétaire collective doivent encore verser 1,56 million avec 60 k disponibles. Vous voyez le tableau ?
Comment un petit 3% d'intérêt devient un drainage massif proche de 95% ? Le phénomène est noyé dans la longue durée et dans un volume qui dépasse l'entendement de tout individu. Le seul moyen de créer les 1,5 M€ manquants, c'est de souscrire un crédit de 1,5 M€. La dette passe alors immédiatement à 3 M€. Cela ne résout jamais le problème, ça l'aggrave.
> C'est ce qu'on appelle une cavalerie : lorsque vous souscrivez un second crédit pour rembourser le premier, puis un troisième pour rembourser le second, ... un jeu qui finit rarement bien.
Il y a deux formes de pénurie structurellement monétaire : d'une part cette pénurie permanente roulante du fait de toujours devoir rendre plus de monnaie qu'il n'en a été créée ; d'autre part une pénurie itérative, erratique, lorsque les banques ferment le robinet. C'est la raison pour laquelle la peur de manquer invite tout le monde à mettre de côté, raison pour laquelle la fonction de valeur de réserve prend le pas sur sa fonction de valeur d'échange.
## Leçon #3 — Ce système monétaire est une pyramide arbitraire qui impose ses valeurs
Dans ce référentiel monétaire, subir ladite Loi du marché n'est pas une option.
[ *fém.* ]
Concrètement, vous allez chez le banquier présenter votre projet selon les critères des banques. Ce qu'ils signifient et les critères qui les définissent appartiennent aux banques, soit une poignée de personnes qui en déclarent les règles de fonctionnement.
En résumé, un très petit nombre de personnes décide ce qui vaut, ce qui est valeur, et ce qui ne l'est pas. Les déchets n'ont pas de valeur, de même les activités dites sociales ou solidaires. En dessous des déchets il y a par exemple le « geste infirmier à domicile ». Tout en haut il y a le traitement massif de la numérisation de tous nos faits et gestes.
Si les personnes ayant besoin d'un soin à domicile déterminaient de façon proportionnelle, selon leurs échelles de valeurs, la répartition des milliards de devises créées chaque jour, ... ces devises seraient-elles réparties de la même façon ?
Tous les projets cherchent la conformité, les requérants jouent la scène qui leur est demandée. Fatalement les projets de petite envergure, ceux dont les modèles éco sortent des sentiers battus, passent rarement les mailles du filet.
> La monnaie-libre et le code génétique qu'elle embarque sont radicalement différents de la monnaie telle que nous croyons la connaître ; elle propose un référentiel relativiste qu'il nous appartient de mettre à notre service. Nous avons maintenant une liberté de choisir, à nous de la saisir.
>
> Mais pouvoir ne signifie pas savoir, encore nous faut-il collectivement apprendre à le faire.
## Une économie mal codée ## Une économie mal codée
L'analogie informatique est éclairante : notre économie est un programme dont le **code source est vicié**. Les monnaies-dette contiennent des bugs génétiques — cavalerie, faillites programmées, pyramide — qui ne sont pas des accidents mais des propriétés structurelles. [ *masc.* ]
La monnaie libre propose de **recoder** la base : une monnaie dont la création est symétrique entre tous les membres, dans l'espace et dans le temps. > On entend de nos jours de belles expressions qui parlent de « dette roulante », ou de « dette qui paie la dette ». Elles sont un voile pudique sur cette cavalerie insoutenable. Le résultat de cette cavalerie, de cette croissance requise effrénée, c'est une extraction de toutes les ressources, planétaires et humaines, qui prend des allures de pillage. Or si vous suivez le raisonnement, ce pillage est inéluctable.
>
> Le besoin monétaire est exponentiel, donc ladite croissance doit l'être, ainsi la consommation d'énergie et l'extraction de toutes les ressources.
> *Chaque monnaie programme sa propre engeance. Ne t'y méprends pas, son pouvoir est immense.* Donc tous ceux qui affirment que la croissance est obligatoire ont raison. Tant que la source monétaire qui abreuve notre économie sera une monnaie-dette.
C'est pour cela qu'en dépit de toutes les COPs, GIEC, les Conventions citoyennes, les intentions et les mobilisations, les chiffres parlent. Il n'y a pas le moindre début d'inflexion dans l'extraction, dans la consommation d'énergie et de matières, dans aucun domaine structurant de fait.
Heureusement localement, il y a d'innombrables expériences et initiatives magnifiques. Dans une vue satellitaire elles sont marginales, mais dans le paysage elles sont vitales.
Pour conclure, si l'on veut mener une réflexion sur la décroissance, sur la sobriété, la contraction, la robustesse non performante, il faut changer de création monétaire. La bonne nouvelle, c'est que la TRM ou la monnaie-libre, propose une, on ne peut plus compatible avec ces réflexions.
> Il s'agit d'embarquer dans une aventure,
> peut-être encore marginale
> mais stratégiquement valable,
> peut-être laborieuse
> mais éventuellement ludique,
> peut-être utopique
> mais, *sans doute*, significative.
Quelle part de nos besoins, quelle part de notre économie pourrait couvrir une production sans plus vraiment d'extraction, sans plus vraiment de « laissés pour compte » ? Jusqu'où peut-on pousser les curseurs ?
+151 -25
View File
@@ -1,51 +1,177 @@
--- ---
title: "La TRM" title: "La TRM"
description: "Invitation à découvrir la Théorie Relative de la Monnaie de Stéphane Laborde : symétrie, DU et relativité." description: "Corréler directement le flux monétaire au flux de la vie humaine — les principes et raisonnements de la Théorie Relative de la Monnaie."
order: 5 order: 5
readingTime: "30 min" page: 83
readingTime: "22 min"
--- ---
## Invitation à la TRM ## La TRM – Théorie Relative de la monnaie
Ce chapitre est une **invitation à lire la Théorie Relative de la Monnaie** (TRM) de Stéphane Laborde. Pas un résumé — la TRM se mérite, se travaille, se digère. Mais quelques clés pour en saisir la portée. *[ fém. ]*
## Le flux monétaire Si l'on veut comprendre la monnaie libre, il est utile de comprendre les principes et raisonnements de la TRM.
En monnaie libre, le flux monétaire est **corrélé à l'existence physique** de chaque être humain. Tant que tu vis, tu co-crées de la monnaie. Le Dividende Universel (DU) est cette part de création, identique pour chaque membre, chaque jour. [ Page de Stéphane Laborde : https://trm.creationmonetaire.info/ ]
## La symétrie spatio-temporelle Je ne parle pas des démonstrations mathématiques, heureusement pour le grand nombre. Mais les postulats, leur positionnement, les questionnements d'origine et la nature des réponses qu'ils apportent.
Le principe fondamental de la TRM tient en deux symétries : Cet essai est directement lié à ma lecture de cette théorie, elle y est présente partout. Je vous en restitue donc une partie pour que vous puissiez suivre ce livre sans avoir lu la TRM.
- **Symétrie spatiale** : aucun individu vivant n'est privilégié par rapport à un autre dans la création monétaire ---
- **Symétrie temporelle** : les individus présents ne sont pas privilégiés par rapport aux individus futurs (ni l'inverse)
Ces deux symétries éliminent les biais génétiques des monnaies-dette : plus de pyramide, plus de privilège d'ancienneté. ## Flux monétaire et vie humaine
## La formule du DU *[ masc. ]*
Le DU se calcule relativement à la masse monétaire existante et au nombre de membres. La formule garantit que, quels que soient le moment d'entrée et la durée de participation, chaque membre converge vers la **même part relative** de la masse monétaire. L'un des fondements de la TRM :
## Relativité : penser en M/N > Corréler directement le flux monétaire au flux de la vie humaine.
En monnaie libre, les montants absolus n'ont pas de sens. Ce qui compte, c'est la **part relative** : combien de DU ? Quel pourcentage de la masse monétaire moyenne par membre (M/N) ? L'image est belle et ce n'est pas qu'une image.
Cette relativité est contre-intuitive au début, mais libératrice : elle élimine le biais de genèse (les premiers arrivés ne sont pas structurellement plus riches) et le biais d'ancienneté. La création monétaire repose ici sur l'existence physique d'un individu. La monnaie que l'individu crée apparaît avec sa naissance dans son bassin éco. De la même façon, cette monnaie créée meurt avec lui, ou lorsqu'il décide de quitter la monnaie et son économie.
## GrateWizard Une propriété mathématique conséquente de la TRM est la « convergence à la moyenne ». En ajoutant une quantité d'unités chaque jour (le DU quotidien), la même pour tout le monde, tous les créateurs monétaires tendent vers le même nombre de DUs. Si vous êtes en-dessous de la moyenne, le nombre de vos DUs augmente ; au-dessus, il diminue. De façon asymptotique, magique mathématique.
L'outil **GrateWizard** permet de simuler et visualiser les flux monétaires en monnaie libre. Il donne à voir la convergence, les échanges, les équilibres — et aide à développer l'intuition de la relativité. La réflexion immédiate qui vient à l'esprit concerne « L'inflation ». Il n'y a pas une mais deux familles d'inflation bien distinctes : une inflation économique (pénurie d'un produit ou de ce qui permet de le produire) et une inflation monétaire (surabondance de monnaie dans l'économie).
## Volume des offres et diversité Du point de vue relativiste dans la monnaie libre, le nombre de DUs finit par atteindre un plafond asymptotique dès que N se stabilise. Dans le référentiel relativiste, il n'y a pas d'inflation monétaire possible.
Une monnaie ne vaut que par ce qu'on peut obtenir avec. Le **volume des offres** et leur **diversité** sont les indicateurs clés de la santé d'une économie en monnaie libre. Plus il y a de producteurs, de services, de biens disponibles, plus la monnaie fait sens. > Or c'est justement ce que fait précisément la monnaie libre. Il y a une création monétaire continue, corrélée aux flux de toutes les vies en cours, répartie également sur chaque personne. Dans le référentiel relativiste, il n'y a pas d'inflation monétaire possible.
## Échelles de valeurs et convergence Et si vous faites l'impasse sur les démonstrations, retenez simplement qu'en comptant en DUs, vous voyez que l'inflation ne peut plus être une question monétaire ; vous voyez que la quantité monétaire créée par un individu naît et meurt avec lui, telle une petite vague qui passe.
Chacun a ses propres échelles de valeurs. La monnaie libre ne les uniformise pas : elle fournit un **référentiel commun** (le DU) qui permet à chacun d'exprimer ses valeurs et de les échanger. Avec le temps, les prix relatifs convergent vers une moyenne, signe que l'économie trouve ses équilibres. C'est – vraiment – beau.
## Le commun monétaire *Pointe une petite larme ? Loin d'être petite, l'arme.*
La monnaie libre est un **commun** : elle n'appartient à personne et à tout le monde. Comme l'air ou l'eau, elle est un bien partagé dont chacun est co-producteur. Ce statut de commun est garanti par la symétrie de sa création. ---
> *Le DU est la part de chacun. Ni plus, ni moins.* ## Symétrie dans l'espace-temps
*[ fém. ]*
Personne en aucun lieu ni aucune époque ne peut créer de la monnaie au détriment de l'accès monétaire d'une autre personne, de tout lieu et de toute époque. Une belle interprétation d'un leitmotiv de John Locke, celui de la non nuisance.
Dans la recherche originelle de Stéphane Laborde, en bon ingénieur, il y avait en premier lieu la recherche d'une mesure invariante. Il pose l'économie en tant que champ en expansion, or la mesure de ce champ ne cesse de fluctuer. Est-il possible de trouver un étalon, une unité de mesure qui ait la même valeur pour toutes, une mesure universelle ?
Cela ne peut pas être le cas de l'or. Dans le champ de l'économie, les référentiels ce sont les gens, nous toutes. Il s'agit donc de trouver la même unité de mesure pour moi ou pour une Papoue de Nouvelle-Guinée, pour une Inuit du 41e siècle. Cet étalon universel ne peut être aucune des valeurs produites ou extraites par l'économie elle-même. Il ne peut donc s'agir que d'un nombre.
> La seule valeur tangible, universelle, et seule valeur qu'on ne puisse pas totalement enlever d'une « zone économique » sans que cette économie s'éteigne immédiatement, c'est … la vie humaine.
Pour que le flux monétaire suive celui de notre vie, son taux de croissance doit être corrélé à la croissance de l'individu. D'où une formule logarithmique sur l'espérance de vie (ev=80), qui donne un taux de croissance proche de 10% annuel.
Bingo, c'est la naissance du Dividende Universel, le DU.
> - Créer soi-même l'unité de mesure de l'économie et toutes nos valeurs, pour manipuler et partager un invariant.
> - Créer soi-même sa part de monnaie, qui suit le flux de sa propre vie, égale à toute autre sur un principe de non-nuisance.
>
> C'est la même chose, la même formule, la formule du DU !
Plutôt génial non ?
[ Formule du DUğ1 : DU*t+1* = DU*t* + c² × (M/N)*t* / 182.625 ]
[ 5, 48 % / *an* ≤ c ≤ 9, 22 % / *an*, selon que l'on prenne ev ou ev/2 pour symétrie ]
*Cela tombe bien : « ex perfecto nihil fit ».*
*[ de la perfection rien ne naît – formule alchimiste ]*
---
## Relativité
*[ masc. ]*
Que peut m'inspirer une unité de mesure universelle relativiste pour estimer les valeurs ? Je peux mesurer toutes les valeurs de façon relative — relativement à la masse monétaire créée dans mon bassin éco, « M » ; et au nombre de personnes « N » qui créent cette monnaie. Estimer les valeurs par rapport à la moyenne « M/N » est un premier repère très intéressant.
Une observation terrain dévoile rapidement un biais de genèse : le gros décalage monétaire entre les personnes qui créent leur DU depuis plus de 8 ans et ceux qui viennent de commencer. C'est paradoxal, car la monnaie-libre fait la promesse d'une « égalité ». Mais c'est une égalité d'accès à la création monétaire, pas une égalité de richesse.
De plus, la richesse n'est pas monétaire ; on s'en rend compte dès que l'on quitte le référentiel de la monnaie-dette. Dans celui de la TRM, ce n'est plus la modalité de création monétaire elle-même qui crée les écarts. C'est dans les économies que nous créerons, par exemple une économie du don, que les écarts peuvent être réduits, sans commune mesure.
---
## Ancienneté
*[ fém. ]*
Pour corriger ce biais de genèse dans les échanges eux-mêmes, on peut appliquer un *coefficient relatif à l'ancienneté*. Une simple formule qui baisse le « prix » pour les nouvelles créatrices et l'augmente pour les plus anciennes, avec un effet qui s'amenuise dans le temps.
J'ai commencé à produire un petit utilitaire pour manipuler facilement ce coefficient. Il suffit de renseigner une valeur de référence pour soi-même et un pourcentage de réduction pour une débutante. L'utilitaire donne alors la valeur relative pour toutes les anciennetés.
[ *GrateWizard* : https://pricewizard.vercel.app ]
Ce coefficient ne fait aucun jugement de valeur sur le comportement économique de l'une ou l'autre partie. C'est un correctif mathématique d'un biais de genèse mathématique, qui s'opère uniquement dans les échanges avec le consentement des parties.
---
## Volume des offres
*[ masc. ]*
Une autre observation terrain : les offres sont réduites en variété et en volume. Pour un problème de volumes limités, on peut estimer les valeurs relativement aux quantités produites. Pratiquer une valeur de référence qui augmente avec le volume reçu — inversion de flux. Prendre trop de volume peut constituer une nuisance à l'autre menacé de pénurie ; on ne l'empêche pas, mais on le régule de façon organique. Cette inversion de flux est très symbolique.
Beaucoup déplorent les déséquilibres entre les personnes qui fournissent beaucoup et d'autres qui « se contentent de consommer ». Nous mettons immédiatement les pieds dans les jugements de valeur moraux. Mais si l'on est réaliste, on sait que c'est inévitable, donc voyons comment réduire ce recours.
---
## Diversité des offres
*[ fém. ]*
Sur les personnes *« qui ne font rien pour recevoir des DUs »*, j'évite de juger et je cherche des vecteurs d'attraction qui déclencheraient le mouvement.
Il peut s'agir initialement d'un produit ou d'un service dont tout le monde fait l'usage. Si mon économie fournit de la bière parce qu'elle aura trouvé le moyen de la produire, alors tout le monde pourra se dire « je peux attraper de la bière artisanale » avec des DUs. Disposer de telles valeurs dans un bassin de vie changerait la donne. Mais à nouveau cela commence par une production.
En attendant, on peut imaginer un coefficient relatif au solde. L'idée : soustraire le nombre de DUs créés de mon solde. S'il reste positif, c'est que j'ai reçu plus de DUs qu'il n'en valorise mes offres reçues ; j'ajuste donc le degré de gratitude en l'augmentant pour les soldes négatifs et en le diminuant pour les soldes positifs.
Coefficients relatifs à la moyenne monétaire, à l'ancienneté, progressifs au volume, relatifs au solde, à la pénibilité, … à tout ce qui vous semble juste. C'est ainsi qu'une économie peut remodeler nos échelles de valeur collectives.
Les vices de notre économie montrent à quel point il est illusoire de vouloir rectifier notre économie euro. Ces leçons donnent donc de bonnes raisons de vouloir en créer une autre. La question n'est pas tant pourquoi se mobiliser, elle est davantage :
> « qui va le faire ? »
---
## Échelles de valeurs
*[ masc. ]*
La question n'est pas tant de considérer un principe puis de dire qu'on y tient beaucoup. Il s'agit davantage, dans un contexte vécu, de positionner ce qui passe devant.
Dès que l'on compte en euros, on navigue dans des chiffres et des ordres de grandeur qui ne nous appartiennent pas. Si l'on trouve qu'un produit est trop cher, c'est évidemment dans une lecture des prix du marché, et non de la valeur que le produit devrait avoir par rapport aux autres, au travail qu'il représente, ni même souvent au besoin que j'en ai.
L'exercice de construire nos propres échelles de valeurs peut paraître difficile, mais il peut devenir amusant. Naturellement nous sommes tous paumés dès que l'on ne compte plus en euros, parce que l'on perd notre référentiel collectif. Mais c'est justement cela qui nous permet de les reconstruire, cette fois-ci de façon totalement décentralisée, sur chaque bassin de vie.
C'est en cela que le DU devient une sorte de condition car c'est notre invariant. Il aura toujours la même valeur relative pour tout le monde. Il nous permet d'avoir un référentiel commun tout en naviguant dans des milliards d'échelles de valeurs différentes.
> Chercher à constituer une seule communauté est une illusion, c'est même selon moi une grosse erreur stratégique. Au contraire, il me semble plus fertile de multiplier les groupes locaux par affinité et proximité, de créer donc « recruter » des équipes ; puis entretenir des relations avec ses groupes voisins et d'autres plus lointains, cultiver des protocoles.
Dès que j'affecte un DU, je signifie une valeur aussi simple soit-elle, cela devient un geste d'investissement, je construis une économie.
---
## Convergence à la moyenne
*[ fém. ]*
Imaginez 2 personnes, l'une avec 1.000, l'autre avec 9.000. L'écart est énorme. En ajoutant 1.000 aux deux, ça donne 2.000 et 10.000. L'autre n'a plus que 5 fois plus. Vous recommencez ; l'écart tombe à facteur 3 (4k contre 12k) ; 4 coups plus tard, l'autre n'a plus que 2 fois plus (8k contre 16k). Ainsi de suite, elles finissent par se rejoindre à la moyenne.
Pas besoin de déshabiller Jacques pour habiller Paul, pas besoin de prélever les uns pour établir des équilibres avec les autres. Plutôt bouleversant par rapport à nos schémas quantitatifs de redistribution monétaire, non ?
Lorsque le nombre de créateurs monétaires sera stable, tout le monde tendra vers une moyenne qui oscille entre 3.742 et 3.925 DUs. Au jour où j'écris ces lignes, la moyenne est à moins de 1.300 DUs.
En contrepartie, j'ai la garantie de ne jamais totalement manquer dans la durée. Dans les passes difficiles, je sais pouvoir compter sur une ressource mutualisée par mon économie, car mon DU me permet de valoriser quelques dons reçus. Un petit espace vital sécurisé, pour peu que cette économie soit suffisamment développée.
---
## Commun monétaire
*[ masc. ]*
> Considérer notre création monétaire comme un commun donne une bonne résonance au terme Dividende Universel, car il suggère le fait d'être tous co-propriétaires à part égale relative du capital monétaire de notre bassin de vie.
Faire des petites projections, mettre un peu de chiffres sur son bassin de vie, est très éclairant. Dans le mien par exemple, un premier jalon repère à 1.024 créateurs monétaires pour une population de 61.444 personnes donnerait un capital commun monétaire de près de 4 millions de DUs. Que pourrait-il couvrir, quelle économie peut-il alimenter ?
Aujourd'hui nous sommes approximativement 150, avec une moyenne au doigt mouillé de 800 DUs dans la vallée, soit 120.000 DUs. Par quoi on commence ?
-42
View File
@@ -1,42 +0,0 @@
---
title: "Créer une économie ?"
description: "Passer de la théorie à la pratique : produire, greffer une économie du don sur le tissu local, inverser les flux."
order: 6
readingTime: "25 min"
---
## Définition
Couvrir les besoins, pour vivre, nourrir les plaisirs de vivre. Avant tout : comment **produire**, comment **distribuer**, sans nuire. On en a déjà une économie. Alors pourquoi en créer une autre ? Pas « créer » au sens de partir de zéro — plutôt **greffer** une économie du don sur l'économie existante.
## Produire avant tout
Le message aux pionniers est clair : faire tourner la monnaie en rond, ce n'est pas créer. Se faire des virements autour d'une table, c'est juste du vent. L'équation de Fischer est formelle : si tu multiplies zéro production par mille transactions, ça fait toujours zéro. L'économie, c'est d'abord **produire et transformer**.
## Passer la seconde
L'analogie du régime moteur : la monnaie libre en est au « point mort ». Pour avancer, il faut **passer la seconde** — produire de vrais biens et services. Cinq vitesses de développement se dessinent, de la simple circulation monétaire à l'économie productive complète.
## L'économie de greffe
Le principe fondamental : **deux économies cohabitent**, l'économie classique (euros) et l'économie du don (monnaie libre). Il ne faut surtout pas les mélanger ni les confondre. On ne « quitte » pas l'euro — on développe une autonomie parallèle, progressive. La greffe prend quand l'économie locale du don couvre suffisamment de besoins réels.
## Connaître son bassin de vie
Avant de se lancer, il faut cartographier son territoire. Deux indicateurs clés :
- Les **mobiz** : les « monnaie-libristes biz » — ceux qui produisent et échangent activement
- Les **sherpiz** : les « sherpas biz » — ceux qui guident, forment, accompagnent
Le ratio mobiz/sherpiz sur un territoire donne une idée réaliste du potentiel de développement.
## La gestion à l'anglaise
Un concept emprunté à la mécanique : la **roue libre**. En monnaie libre, la « roue libre » est l'indicateur de rentabilité. Quand une activité tourne sans effort, en roue libre, c'est qu'elle est rentable en DU. Ce n'est pas la marge financière qui compte, c'est la **soutenabilité** du geste productif.
## L'économie de flux inversés
Le retournement fondamental : **je donne avant de recevoir**. En économie classique, le flux est conditionnel — je te donne si tu me payes. En économie du don, je donne d'abord, et la mesure (en DU) vient après, comme une gratitude.
Ce n'est pas de la naïveté : c'est un changement de posture qui transforme la relation économique. Le don est en amont. L'équipe offre le choix, le lieu, le son. Les références sont posées. Tu prends ou pas, tu gratifies selon ton estimation.
> *L'économie, c'est de l'énergie, de la chaleur c'est sûr. Je grave ma gratitude dans la chaîne.*

Some files were not shown because too many files have changed in this diff Show More