feat: barre d'outils de lecture epinglee, coloration syntaxique des fichiers de code et avatars predefinis (#115, #117, BUG-078)
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
||||
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
> **En cours de développement** : les changements à venir sont listés dans la section
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.24.0**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.25.0**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,34 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [2.25.0] — 2026-09-24
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#115 — barre d'outils de lecture toujours visible.** La barre d'actions d'un
|
||||
document (pop-out, bookmark, Editer, Source, Copier, Partager…) est désormais
|
||||
**épinglée en haut** de la zone de lecture : elle reste accessible en permanence,
|
||||
même au bas d'un document long, au lieu de disparaître au défilement. Elle est
|
||||
rendue comme un enfant direct du conteneur de défilement (`.file-toolbar`), masquée
|
||||
en mode lecture, et alignée dans la fenêtre pop-out.
|
||||
- **#117 — avatars prédéfinis dans le profil.** La section **Profil** propose une
|
||||
galerie de **12 avatars** fournis avec l'application (`frontend/icons/avatar/`) :
|
||||
un clic charge l'image, la recadre au format carré 256 px (même pipeline que
|
||||
l'import) et l'enregistre sur le compte. L'avatar actif est surligné ; l'import
|
||||
d'une photo personnalisée et la suppression restent disponibles.
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-078 — coloration syntaxique des fichiers de code perdue.** Les feuilles de
|
||||
style highlight.js étaient basculées à partir de la **clé de thème**
|
||||
(`defaut-obsigate`, …) au lieu du **mode** (`dark`/`light`) : les deux feuilles se
|
||||
retrouvaient désactivées et les blocs de code (`.py`, `.sh`, `.ps1`, `.yml`, JSON,
|
||||
blocs Markdown…) s'affichaient en texte brut. Le basculement est désormais piloté
|
||||
par le mode, de façon déterministe, dans le moteur de thème (`themes.js`) comme au
|
||||
premier rendu (`ui.js`) ; sépia et contraste élevé réutilisent la palette claire.
|
||||
|
||||
---
|
||||
|
||||
## [2.24.0] — 2026-09-24
|
||||
|
||||
### Ajouté
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -975,8 +975,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.24.0).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.25.0).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.24.0 | Dernière mise à jour : Septembre 2026*
|
||||
*Projet : ObsiGate | Version : 2.25.0 | Dernière mise à jour : Septembre 2026*
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -1150,8 +1150,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.24.0).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.25.0).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.24.0 | Last updated: September 2026*
|
||||
*Project: ObsiGate | Version: 2.25.0 | Last updated: September 2026*
|
||||
|
||||
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.24.0"
|
||||
version = "2.25.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.24.0"
|
||||
version = "2.25.0"
|
||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||
authors = ["Bruno Charest"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||
"productName": "ObsiGate",
|
||||
"version": "2.24.0",
|
||||
"version": "2.25.0",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
@@ -187,6 +187,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| *BUG-075* | [🟡 IMPORTANT] Assistant IA : chaque action mutatrice demande son propre « Appliquer » — aucun résumé des actions en attente ni approbation globale | 🟢 corrigé | P1 | ⚙️ backend + 📱 frontend | IA | `backend/agent/loop.py`, `backend/bookslm_routes.py`, `frontend/js/bookslm.js` | Mode agent : demander une structure de répertoires multi-fichiers → valider une action après l'autre | `backend/agent/loop.py` (`pending.actions`, lot exécuté au resume) ; `backend/bookslm_routes.py` (`confirm_all` → `ctx.confirmed`) ; `frontend/js/bookslm.js` (carte multi-actions + « Tout approuver (N) »). Tests : `tests/test_agent_loop.py`, `tests/test_bookslm.py`, `tests/frontend/ai.test.mjs` | La pause de confirmation ne capture que le **premier** appel mutateur du lot (les suivants sont `deferred`) ; carte unique sans liste ; nouveau `confirm_all` à ajouter pour autoriser la suite de l'exécution en une approbation |
|
||||
| *BUG-076* | [🟡 IMPORTANT] Assistant IA : après une action de l'agent, l'arborescence et le document ouvert ne sont pas rafraîchis dynamiquement | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Mode agent : créer/supprimer un fichier ou dossier, modifier le document ouvert → l'UI ne bouge pas | `frontend/js/bookslm.js` : `MUTATING_TOOLS`/`FILE_WRITE_TOOLS`, refresh d'arborescence débouncé sur event `tool`, `_notifyFileWritten` étendu (xlsx/docx/csv/pdf). Tests : `tests/frontend/ai.test.mjs`, `tests/frontend/editor-inline.test.mjs` | Aucun refresh explicite sur les événements `tool` mutateurs (repose uniquement sur le watcher SSE) ; `_notifyFileWritten` ignore les créations de documents (xlsx/docx/csv/pdf) |
|
||||
| *BUG-077* | [🟡 IMPORTANT] Assistant IA : aucun bouton « Stop » pour arrêter l'exécution de l'agent à tout moment | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Mode agent : lancer une longue tâche → le bouton Envoyer est désactivé, impossible d'arrêter (seule la fermeture du panneau abort) | `frontend/js/bookslm.js` + `frontend/style.css` : bouton d'envoi → Stop (`_syncSendButton`/`_stopGeneration`/`_markStopped`), i18n `ai.stop`/`ai.stopped`. Tests : `tests/frontend/ai.test.mjs` (+2) | `_abortCtrl` n'est déclenché que par `close()` ; aucun signal d'arrêt côté client pendant le stream |
|
||||
| *BUG-078* | [🟡 IMPORTANT] Fichiers de code : la coloration syntaxique (highlight.js) disparaît — les feuilles de thème sont basculées à partir de la **clé** de thème au lieu du **mode** | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/themes.js`, `frontend/js/ui.js`, `tests/frontend/unit.test.mjs` | Ouvrir un fichier `.py`/`.sh`/`.ps1`/`.yml` : le code s'affiche en texte brut, sans couleurs | `frontend/js/themes.js` : `applyTheme` bascule `hljs-theme-dark`/`hljs-theme-light` selon le **mode** (`isDark`). `frontend/js/ui.js` : `initTheme`/`applyTheme` résolvent le mode persisté (`obsigate-theme-mode`) au lieu de traiter la clé (`defaut-obsigate`) comme un mode. Test : `unit.test.mjs` (+1). | Les deux feuilles étaient désactivées car `defaut-obsigate !== "dark"` et `!== "light"` ; résultat **non déterministe** selon l'ordre `UI.initTheme()` (clé) / `Sync.init()` → `themes.initThemes()` (mode). Vérifié Playwright : 5/5 chargements colorés (`.py`), sépia/contraste élevé sur la palette claire |
|
||||
|
||||
### TODOs techniques (améliorations / nouvelles tâches)
|
||||
|
||||
@@ -269,6 +270,8 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| 2026-09-24 | #114 | Feature | `frontend/index.html`, `frontend/js/config.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/config-mobile.test.mjs`, `tests/e2e/config-mobile.spec.js`, `docs/features/settings-mobile-114.md` (nouvelle), `docs/ROADMAP.md`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **#114 — Configuration, refonte mobile-responsive (≤768px)**. (1) **Drawer sommaire** : `#config-nav` en panneau `position: fixed` (`min(320px, 88vw)`, z-index 40) sous backdrop `#config-modal.config-toc-open::before` (z-index 35) ; bouton `#config-toc-close` ; backdrop/Échap ferment le drawer d'abord puis la modale ; `_setConfigNav` bascule la classe + conserve le `display` inline de reset. (2) **Modale plein écran** : `100dvh` + `padding: 0`, `border-radius: 0`. (3) **Tactile** : boutons/liens ≥44px, inputs/selects `16px` + `min-height: 44px` (anti-zoom iOS, scopé `#config-modal`), rangée `.config-actions-row` sticky column + safe-area, formulaires 1 colonne, MFA 1 colonne + code full-width, wrap webhook/token/share/diag/avatar/webauthn. (4) **Dettes HTML/i18n** : `.config-actions-row` replacée dans `#cfg-backend-settings` (`</section>` orphelin supprimé), id dupliqué `cfg-partages-publics` retiré du `<h2>`, `#plugins-settings-container` supprimé, doublons `.config-btn-add` + règle morte `.mfa-recovery-input` purgés, `#mt-explorer` → `data-i18n="settings.explorer"` ; i18n : clés mortes `settings.{backend,backend_hint,restart_badge,save,plugins}` supprimées, `settings.explorer` + `config.toc_close` ajoutées, `settings.tabs` FR = « Onglets ». Vérifié : `config-mobile.test.mjs` 27/27 (au CI), unit 11/11, validate-imports 40 modules, pytest 1302 passed / 6 skipped, ruff/mypy 0, E2E `chromium-mobile` 5/5. | ✅ livré (en attente vérif utilisateur) |
|
||||
| 2026-09-24 | BUG-074 → BUG-077 | Correction | `backend/agent/loop.py`, `backend/bookslm_routes.py`, `frontend/js/bookslm.js`, `frontend/style.css`, `frontend/index.html`, `frontend/locales/{fr,en}.json`, `frontend/sw.js`, `tests/test_agent_loop.py`, `tests/test_bookslm.py`, `tests/frontend/ai.test.mjs`, `tests/frontend/editor-inline.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **Lot assistant IA (mode agent)** : (BUG-075) confirmation par lot — `pending.actions` regroupe toutes les mutations d'un tour LLM, un unique bouton « Tout approuver (N) » envoie `confirm_all` (`ToolContext.confirmed`) et n'interrompt plus à chaque action ; les lectures du lot s'exécutent aussitôt. (BUG-074) résumé du bloc d'étapes avec titre de la 1re action + compteur limité aux actions, reprise diffusée dans le même message (fini le « 1 étape » fragmenté). (BUG-076) refresh de l'arborescence débouncé sur les events `tool` mutateurs + `_notifyFileWritten` étendu aux documents (xlsx/docx/csv/pdf). (BUG-077) le bouton d'envoi devient « Stop » pendant le stream (abort SSE, tâche serveur annulée à la déconnexion, marqueur « Exécution arrêtée. »). Guide/i18n FR/EN + `ai.stop`/`ai.stopped`/`ai.confirm_actions`/`ai.action_apply_all` ; `SW_VERSION` v25. Vérifié : pytest 1304 passed / 6 skipped, ruff/mypy 0, validate-imports 40 modules, unit 11/11, ai 100/100, editor-inline 44/44, mobile-editor 35/35, ai-sidebar 6/6, forge 32/32, pane-manager 9/9, sw 8/8. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
|
||||
| 2026-09-24 | #115, #117, BUG-078 | Feature + correction | `frontend/js/themes.js`, `frontend/js/ui.js`, `frontend/js/viewer.js`, `frontend/js/config.js`, `frontend/index.html`, `frontend/style.css`, `frontend/popout.html`, `frontend/locales/{fr,en}.json`, `frontend/icons/avatar/*` (nouveau), `tests/frontend/unit.test.mjs`, `tests/frontend/toolbar-order.test.mjs`, `tests/frontend/settings-order-avatar.test.mjs`, `docs/features/viewer-toolbar-highlight-avatars.md` (nouvelle), `docs/ROADMAP.md`, `CHANGELOG.md` | **#115** barre d'outils de lecture épinglée : `viewer.js`/`popout.html` sortent `.file-actions` de `.file-header` dans un `.file-toolbar` enfant direct de `.content-area` (`position: sticky; top: 0`), masqué en mode lecture. **BUG-078** coloration syntaxique : le basculement des feuilles highlight.js suit le **mode** (`themes.applyTheme` + `ui.initTheme/applyTheme` lisent `obsigate-theme-mode`) au lieu de la clé de thème qui désactivait les deux feuilles. **#117** avatars prédéfinis : galerie de 12 images (`frontend/icons/avatar/`) dans `#cfg-profile`, clic → recadrage 256 px (pipeline import) + `PATCH /api/auth/me`, avatars actifs surlignés (`obsigate-avatar-preset`), import personnalisé et suppression conservés. Vérifié : Playwright (coloration 5/5 déterministe, toolbar épinglée à `barTop` constant au défilement), `unit.test.mjs` 12/12, `toolbar-order` 13/13, `settings-order-avatar` 12/12, JSDOM editor-inline/pane-manager/mobile-editor/image-viewer/pdf-viewer/config-mobile/media-viewer/excalidraw verts, pytest 1304 passed / 6 skipped, ruff/mypy 0, validate-imports 40 modules. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
|
||||
---
|
||||
|
||||
## 📜 Historique des bugs résolus
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.24.0 | **Dernière mise à jour :** 2026-09-24
|
||||
> **Version :** 2.25.0 | **Dernière mise à jour :** 2026-09-24
|
||||
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||
> vers les fonctionnalités livrées.
|
||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
||||
@@ -189,6 +189,9 @@
|
||||
| 112 | En-tête allégé & compte en sidebar — version dans le menu Options, retrait utilisateur/déconnexion du header, section compte en bas de la sidebar, pellicule d'images défilable (molette + flèches) | 2.21.0 | [features/header-user-sidebar-112.md](./features/header-user-sidebar-112.md) |
|
||||
| 113 | Configuration — ordre naturel des sections (Profil 1er, À propos dernier, TOC = page) & avatar utilisateur (import PNG/JPG/WEBP, persistance serveur, cercle sidebar) | 2.22.0 | [features/settings-order-avatar-113.md](./features/settings-order-avatar-113.md) |
|
||||
| 114 | Configuration — refonte mobile-responsive (modale plein écran 100dvh, sommaire en drawer coulissant, cibles tactiles ≥ 44 px, inputs 16 px anti-zoom, sauvegarde sticky, MFA 1 colonne, purge i18n/HTML) | 2.23.0 | [features/settings-mobile-114.md](./features/settings-mobile-114.md) |
|
||||
| BUG-078 | Fichiers de code — coloration syntaxique restaurée (feuilles highlight.js basculées sur le mode de thème et non la clé) | 2.25.0 | [features/viewer-toolbar-highlight-avatars.md](./features/viewer-toolbar-highlight-avatars.md) |
|
||||
| 115 | Viewer — barre d'outils de lecture épinglée au défilement | 2.25.0 | [features/viewer-toolbar-highlight-avatars.md](./features/viewer-toolbar-highlight-avatars.md) |
|
||||
| 117 | Configuration — avatars prédéfinis dans le profil utilisateur (12 images) | 2.25.0 | [features/viewer-toolbar-highlight-avatars.md](./features/viewer-toolbar-highlight-avatars.md) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# #115 / BUG-078 / #117 — Barre d'outils épinglée, coloration syntaxique des fichiers de code & avatars prédéfinis
|
||||
|
||||
> **Statut :** 🟢 livré (en attente vérification utilisateur)
|
||||
> **Impact :** 🟡 (#115, BUG-078) · 🟢 (#117)
|
||||
> **Zone :** frontend (`frontend/js/viewer.js`, `frontend/js/themes.js`,
|
||||
> `frontend/js/ui.js`, `frontend/js/config.js`, `frontend/index.html`,
|
||||
> `frontend/style.css`, `frontend/popout.html`, `frontend/locales/fr.json`,
|
||||
> `frontend/locales/en.json`, `frontend/icons/avatar/*`)
|
||||
|
||||
Trois demandes traitées dans la même livraison, toutes côté frontend.
|
||||
|
||||
## #115 — Barre d'outils de lecture toujours visible
|
||||
|
||||
### Problème
|
||||
|
||||
La barre d'actions d'un document (pop-out, bookmark, Editer, Source, Copier, PDF, Export,
|
||||
Partager…) était rendue **dans** `.file-header`, un conteneur court placé en haut de la
|
||||
zone de lecture. Or un élément `position: sticky` reste borné par son parent : dès que
|
||||
`.file-header` sortait de l'écran au défilement d'un document long, la barre disparaissait.
|
||||
|
||||
### Correctif
|
||||
|
||||
- `frontend/js/viewer.js` et `frontend/popout.html` sortent la barre d'actions de
|
||||
`.file-header` et la placent dans un nouveau conteneur `.file-toolbar`, **enfant direct
|
||||
de `.content-area`** (le conteneur de défilement). La barre peut donc se coller au haut
|
||||
de la zone de lecture pour toute la hauteur du document.
|
||||
- `frontend/style.css` :
|
||||
|
||||
```css
|
||||
.file-toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
margin: 0 0 16px;
|
||||
padding: 8px 0;
|
||||
background: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
```
|
||||
|
||||
Le fond est opaque pour qu'aucun texte ne transparaisse dessous.
|
||||
- `body.reading-mode .file-toolbar { display: none }` : la barre reste masquée en mode
|
||||
lecture, comme les autres actions.
|
||||
|
||||
## BUG-078 — Coloration syntaxique des fichiers de code
|
||||
|
||||
### Problème
|
||||
|
||||
Les fichiers `.py`, `.sh`, `.ps1`, `.yml`, `.json`… (et les blocs de code Markdown)
|
||||
s'affichaient en **texte brut**, sans couleurs. Le code était pourtant correctement
|
||||
généré côté backend (`<pre><code class="language-python">…`) et `safeHighlight()` appelait
|
||||
bien `hljs.highlightElement()`.
|
||||
|
||||
La cause était ailleurs : les deux feuilles de style de highlight.js (`#hljs-theme-dark`
|
||||
et `#hljs-theme-light`, chargées depuis le CDN dans `index.html`) étaient basculées à
|
||||
partir de la **clé de thème** persistée (`obsigate-theme` = `defaut-obsigate`, …) :
|
||||
|
||||
```js
|
||||
darkSheet.disabled = theme !== "dark"; // "defaut-obsigate" !== "dark" → true
|
||||
lightSheet.disabled = theme !== "light"; // "defaut-obsigate" !== "light" → true
|
||||
```
|
||||
|
||||
Les deux feuilles finissaient donc désactivées, privant tous les tokens de leurs couleurs.
|
||||
Le résultat dépendait de l'ordre de deux initialisations concurrentes — `UI.initTheme()`
|
||||
(clé de thème) au démarrage et `themes.initThemes()` (mode) via `Sync.init()` — d'où un
|
||||
comportement **non déterministe** (parfois coloré, le plus souvent non).
|
||||
|
||||
### Correctif
|
||||
|
||||
- `frontend/js/themes.js` — `applyTheme(themeKey, mode)` bascule désormais les feuilles
|
||||
highlight.js selon le **mode** :
|
||||
|
||||
```js
|
||||
var isDark = mode === 'dark';
|
||||
darkSheet.disabled = !isDark;
|
||||
lightSheet.disabled = isDark;
|
||||
```
|
||||
|
||||
(`sepia` et `high-contrast` réutilisent la palette claire.)
|
||||
- `frontend/js/ui.js` — `initTheme()` lit le **mode** persisté (`obsigate-theme-mode`) et
|
||||
`applyTheme()` résout un mode avant de fixer `data-theme` et de basculer les feuilles,
|
||||
au lieu de comparer la clé de thème à `"dark"`/`"light"`.
|
||||
|
||||
Le basculement est ainsi **déterministe** dès le premier rendu et à chaque changement de
|
||||
mode.
|
||||
|
||||
## #117 — Avatars prédéfinis dans le profil
|
||||
|
||||
### Ce qui a été livré
|
||||
|
||||
- **Galerie de 12 avatars** dans la section `Profil` (`#cfg-profile`), servis depuis
|
||||
`frontend/icons/avatar/` (`/static/icons/avatar/<fichier>.jpg`) : Chat, chien, elephan,
|
||||
hibou, koala, lapin, lion, ours, penda, pingouin, raton, tigre.
|
||||
- Un clic charge l'image, la fait passer par le **même pipeline que l'import** (recadrage
|
||||
carré central, redimensionnement 256 px, export JPEG 0,85) et l'enregistre via
|
||||
`PATCH /api/auth/me` — aucune modification backend n'a été nécessaire, la validation
|
||||
data-URL PNG/JPEG/WebP existante (BUG-113) s'applique telle quelle.
|
||||
- L'avatar actif est **surligné** ; le choix est mémorisé dans `localStorage`
|
||||
(`obsigate-avatar-preset`) et purgé dès qu'on importe une photo personnalisée ou qu'on
|
||||
supprime l'avatar.
|
||||
- L'import d'une photo et la suppression restent disponibles.
|
||||
- i18n : nouvelle clé `config.avatar_presets_label` (FR/EN).
|
||||
|
||||
## Tests
|
||||
|
||||
- `tests/frontend/unit.test.mjs` — `syntax highlight theme` (BUG-078) : `themes.applyTheme`
|
||||
bascule les feuilles selon le mode et `ui.js` ne compare plus la clé au mode.
|
||||
- `tests/frontend/toolbar-order.test.mjs` — #115 : `.file-toolbar` dans `viewer.js` et
|
||||
`popout.html`, règle CSS `position: sticky; top: 0`, masquage en mode lecture.
|
||||
- `tests/frontend/settings-order-avatar.test.mjs` — #117 : 12 avatars présents dans
|
||||
`#cfg-profile`, fichiers d'images existants, pipeline `config.js`, règles CSS, clé i18n
|
||||
FR/EN.
|
||||
- Vérification Playwright (instance locale, auth désactivée) : coloration déterministe sur
|
||||
5 chargements successifs d'un `.py` ; `.file-toolbar` dont le `top` ne bouge plus après
|
||||
défilement (épinglage effectif).
|
||||
|
||||
## Limitations
|
||||
|
||||
- L'avatar reste stocké en data-URL 256 px dans `data/users.json` (comportement #113
|
||||
conservé).
|
||||
- Le mode `high-contrast`/`sepia` utilise le thème highlight.js **clair** (pas de palette
|
||||
dédiée).
|
||||
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 194 KiB |
@@ -1660,6 +1660,27 @@
|
||||
<span class="profile-hint" data-i18n="config.avatar_hint">PNG, JPG ou WEBP — image carrée recadrée et réduite à 256 px.</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="profile-avatar-label" data-i18n="config.avatar_presets_label">Avatars prédéfinis</span>
|
||||
<div
|
||||
class="profile-avatar-presets"
|
||||
id="profile-avatar-presets"
|
||||
role="radiogroup"
|
||||
data-i18n-attr="aria-label:config.avatar_presets_label"
|
||||
aria-label="Avatars prédéfinis"
|
||||
>
|
||||
<button type="button" class="profile-avatar-preset" data-avatar="Chat.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/Chat.jpg" alt="" loading="lazy" /></button>
|
||||
<button type="button" class="profile-avatar-preset" data-avatar="chien.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/chien.jpg" alt="" loading="lazy" /></button>
|
||||
<button type="button" class="profile-avatar-preset" data-avatar="elephan.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/elephan.jpg" alt="" loading="lazy" /></button>
|
||||
<button type="button" class="profile-avatar-preset" data-avatar="hibou.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/hibou.jpg" alt="" loading="lazy" /></button>
|
||||
<button type="button" class="profile-avatar-preset" data-avatar="koala.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/koala.jpg" alt="" loading="lazy" /></button>
|
||||
<button type="button" class="profile-avatar-preset" data-avatar="lapin.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/lapin.jpg" alt="" loading="lazy" /></button>
|
||||
<button type="button" class="profile-avatar-preset" data-avatar="lion.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/lion.jpg" alt="" loading="lazy" /></button>
|
||||
<button type="button" class="profile-avatar-preset" data-avatar="ours.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/ours.jpg" alt="" loading="lazy" /></button>
|
||||
<button type="button" class="profile-avatar-preset" data-avatar="penda.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/penda.jpg" alt="" loading="lazy" /></button>
|
||||
<button type="button" class="profile-avatar-preset" data-avatar="pingouin.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/pingouin.jpg" alt="" loading="lazy" /></button>
|
||||
<button type="button" class="profile-avatar-preset" data-avatar="raton.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/raton.jpg" alt="" loading="lazy" /></button>
|
||||
<button type="button" class="profile-avatar-preset" data-avatar="tigre.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/tigre.jpg" alt="" loading="lazy" /></button>
|
||||
</div>
|
||||
<p class="profile-avatar-error hidden" id="profile-avatar-error" role="alert"></p>
|
||||
</div>
|
||||
<div class="profile-field">
|
||||
|
||||
@@ -2534,6 +2534,7 @@ function initProfile() {
|
||||
var overlayBtn = document.getElementById('profile-avatar-overlay');
|
||||
var previewBox = document.getElementById('profile-avatar-preview');
|
||||
var removeBtn = document.getElementById('profile-avatar-remove');
|
||||
var presetsBox = document.getElementById('profile-avatar-presets');
|
||||
if (!avatarField || !avatarInput || !chooseBtn) return;
|
||||
|
||||
// The avatar only exists on a real account — hide it when auth is off.
|
||||
@@ -2554,6 +2555,59 @@ function initProfile() {
|
||||
})
|
||||
.catch(function () { /* non-bloquant */ });
|
||||
|
||||
// ── Preset avatars (#117) ────────────────────────────────────────
|
||||
var PRESET_STORAGE_KEY = 'obsigate-avatar-preset';
|
||||
function markActivePreset(name) {
|
||||
if (!presetsBox) return;
|
||||
presetsBox.querySelectorAll('.profile-avatar-preset').forEach(function (btn) {
|
||||
var on = !!name && btn.getAttribute('data-avatar') === name;
|
||||
btn.classList.toggle('active', on);
|
||||
btn.setAttribute('aria-checked', on ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
function clearActivePreset() {
|
||||
try { localStorage.removeItem(PRESET_STORAGE_KEY); } catch (e) { /* ignore */ }
|
||||
markActivePreset(null);
|
||||
}
|
||||
function rememberActivePreset(name) {
|
||||
try { localStorage.setItem(PRESET_STORAGE_KEY, name); } catch (e) { /* ignore */ }
|
||||
markActivePreset(name);
|
||||
}
|
||||
var savedPreset = null;
|
||||
try { savedPreset = localStorage.getItem(PRESET_STORAGE_KEY); } catch (e) { /* ignore */ }
|
||||
markActivePreset(savedPreset);
|
||||
|
||||
if (presetsBox) {
|
||||
presetsBox.querySelectorAll('.profile-avatar-preset').forEach(function (btn) {
|
||||
btn.addEventListener('click', async function () {
|
||||
var preset = btn.getAttribute('data-avatar');
|
||||
if (!preset) return;
|
||||
_profileAvatarError(null);
|
||||
var all = presetsBox.querySelectorAll('.profile-avatar-preset');
|
||||
all.forEach(function (b) { b.disabled = true; });
|
||||
try {
|
||||
// Load the bundled image, then reuse the upload pipeline (center-crop
|
||||
// + 256 px JPEG) so it stores exactly like an imported picture.
|
||||
var res = await fetch('/static/icons/avatar/' + encodeURIComponent(preset), { credentials: 'include' });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
var blob = await res.blob();
|
||||
var dataUrl = await _resizeAvatarFile(blob);
|
||||
var user = await _patchProfileAvatar(dataUrl);
|
||||
AuthManager.updateCachedUser(user);
|
||||
_renderProfileAvatar(user.avatar || dataUrl);
|
||||
AuthManager.renderUserSection();
|
||||
rememberActivePreset(preset);
|
||||
showToast(t('config.avatar_updated'), 'success');
|
||||
} catch (err) {
|
||||
_profileAvatarError('config.avatar_upload_failed');
|
||||
console.error('Avatar preset failed:', err);
|
||||
} finally {
|
||||
all.forEach(function (b) { b.disabled = false; });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function openPicker() {
|
||||
_profileAvatarError(null);
|
||||
avatarInput.click();
|
||||
@@ -2587,6 +2641,7 @@ function initProfile() {
|
||||
AuthManager.updateCachedUser(user);
|
||||
_renderProfileAvatar(user.avatar || dataUrl);
|
||||
AuthManager.renderUserSection();
|
||||
clearActivePreset(); // an imported photo is not a preset
|
||||
showToast(t('config.avatar_updated'), 'success');
|
||||
} catch (err) {
|
||||
_profileAvatarError('config.avatar_upload_failed');
|
||||
@@ -2605,6 +2660,7 @@ function initProfile() {
|
||||
AuthManager.updateCachedUser(user);
|
||||
_renderProfileAvatar(null);
|
||||
AuthManager.renderUserSection();
|
||||
clearActivePreset();
|
||||
showToast(t('config.avatar_removed'), 'success');
|
||||
} catch (err) {
|
||||
_profileAvatarError('config.avatar_upload_failed');
|
||||
|
||||
@@ -598,6 +598,17 @@ function applyTheme(themeKey, mode) {
|
||||
// Notify other components of theme change
|
||||
try {
|
||||
root.setAttribute('data-theme', mode);
|
||||
// Syntax highlighting follows the light/dark mode (BUG-078): the theme
|
||||
// *key* is not a mode, so toggling the sheets by key used to disable both
|
||||
// and strip every code block of its colours. Sepia/high-contrast reuse the
|
||||
// light palette.
|
||||
var darkSheet = document.getElementById('hljs-theme-dark');
|
||||
var lightSheet = document.getElementById('hljs-theme-light');
|
||||
if (darkSheet && lightSheet) {
|
||||
var isDark = mode === 'dark';
|
||||
darkSheet.disabled = !isDark;
|
||||
lightSheet.disabled = isDark;
|
||||
}
|
||||
document.dispatchEvent(new CustomEvent('themechange', { detail: { theme: themeKey, mode: mode } }));
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
@@ -137,14 +137,26 @@ export const RightSidebarManager = {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Theme
|
||||
// ---------------------------------------------------------------------------
|
||||
const _THEME_MODES = ["dark", "light", "high-contrast", "sepia"];
|
||||
|
||||
export function initTheme() {
|
||||
const saved = localStorage.getItem("obsigate-theme") || "dark";
|
||||
applyTheme(saved);
|
||||
// The theme engine (themes.js) owns the CSS variables and the theme *key*;
|
||||
// the <html data-theme> attribute must carry the *mode* (dark/light/…). Read
|
||||
// the persisted mode here so the first paint and the highlight.js stylesheet
|
||||
// are right before the async theme init runs (BUG-078).
|
||||
let mode = "dark";
|
||||
try { mode = localStorage.getItem("obsigate-theme-mode") || "dark"; } catch (e) { /* ignore */ }
|
||||
applyTheme(mode);
|
||||
}
|
||||
|
||||
export function applyTheme(theme) {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
localStorage.setItem("obsigate-theme", theme);
|
||||
let mode = theme;
|
||||
if (_THEME_MODES.indexOf(mode) === -1) {
|
||||
// Callers may pass a theme key (legacy): fall back to the persisted mode.
|
||||
try { mode = localStorage.getItem("obsigate-theme-mode") || "dark"; } catch (e) { mode = "dark"; }
|
||||
}
|
||||
const isDark = mode === "dark";
|
||||
document.documentElement.setAttribute("data-theme", mode);
|
||||
|
||||
// Update theme button icon and label
|
||||
const themeBtn = document.getElementById("theme-toggle");
|
||||
@@ -152,23 +164,23 @@ export function applyTheme(theme) {
|
||||
if (themeBtn && themeLabel) {
|
||||
const icon = themeBtn.querySelector("i");
|
||||
if (icon) {
|
||||
icon.setAttribute("data-lucide", theme === "dark" ? "moon" : "sun");
|
||||
icon.setAttribute("data-lucide", isDark ? "moon" : "sun");
|
||||
}
|
||||
themeLabel.textContent = theme === "dark" ? t('theme.dark') : t('theme.light');
|
||||
themeLabel.textContent = isDark ? t('theme.dark') : t('theme.light');
|
||||
safeCreateIcons();
|
||||
}
|
||||
|
||||
// Swap highlight.js theme
|
||||
// Swap highlight.js theme — keyed on the mode, not the theme key (BUG-078).
|
||||
const darkSheet = document.getElementById("hljs-theme-dark");
|
||||
const lightSheet = document.getElementById("hljs-theme-light");
|
||||
if (darkSheet && lightSheet) {
|
||||
darkSheet.disabled = theme !== "dark";
|
||||
lightSheet.disabled = theme !== "light";
|
||||
darkSheet.disabled = !isDark;
|
||||
lightSheet.disabled = isDark;
|
||||
}
|
||||
|
||||
// Update Mermaid theme for newly rendered diagrams
|
||||
import('./mermaid-viewer.js').then(function(m) {
|
||||
m.updateMermaidTheme(theme === 'dark');
|
||||
m.updateMermaidTheme(isDark);
|
||||
}).catch(function() {});
|
||||
}
|
||||
|
||||
|
||||
@@ -1418,7 +1418,12 @@ export function renderFile(data) {
|
||||
// Assemble
|
||||
area.innerHTML = "";
|
||||
area.appendChild(breadcrumb);
|
||||
area.appendChild(el("div", { class: "file-header" }, [el("div", { class: "file-title" }, [document.createTextNode(data.title)]), tagsDiv, el("div", { class: "file-actions" }, fileActions)]));
|
||||
area.appendChild(el("div", { class: "file-header" }, [el("div", { class: "file-title" }, [document.createTextNode(data.title)]), tagsDiv]));
|
||||
// #115 — the action bar is a direct child of the scroll container (wrapped in
|
||||
// `.file-toolbar`) so it can stay pinned while long documents scroll; nested
|
||||
// inside `.file-header` it would stop sticking as soon as the header left the
|
||||
// viewport.
|
||||
area.appendChild(el("div", { class: "file-toolbar" }, [el("div", { class: "file-actions" }, fileActions)]));
|
||||
if (fmSection) area.appendChild(fmSection);
|
||||
area.appendChild(mdDiv);
|
||||
area.appendChild(rawDiv);
|
||||
|
||||
@@ -386,6 +386,7 @@
|
||||
"config.key_deleted": "Key deleted:",
|
||||
"config.avatar_label": "Profile picture",
|
||||
"config.avatar_hint": "PNG, JPG or WEBP — square image cropped and resized to 256 px. Shown in the sidebar.",
|
||||
"config.avatar_presets_label": "Or pick a preset avatar",
|
||||
"config.avatar_choose": "Choose an image",
|
||||
"config.avatar_remove": "Remove picture",
|
||||
"config.avatar_updated": "Profile picture updated",
|
||||
|
||||
@@ -386,6 +386,7 @@
|
||||
"config.key_deleted": "Clé supprimée :",
|
||||
"config.avatar_label": "Photo de profil",
|
||||
"config.avatar_hint": "PNG, JPG ou WEBP — image carrée recadrée et réduite à 256 px. Apparaît dans la barre latérale.",
|
||||
"config.avatar_presets_label": "Ou choisissez un avatar prédéfini",
|
||||
"config.avatar_choose": "Choisir une image",
|
||||
"config.avatar_remove": "Supprimer la photo",
|
||||
"config.avatar_updated": "Photo de profil mise à jour",
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="/static/style.css?v=2">
|
||||
<link rel="stylesheet" href="/static/style.css?v=3">
|
||||
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" id="hljs-theme-dark">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css" id="hljs-theme-light" disabled>
|
||||
@@ -871,8 +871,13 @@ const RightSidebarManager = {
|
||||
group.forEach(function (b) { actionsDiv.appendChild(b); });
|
||||
});
|
||||
|
||||
header.appendChild(actionsDiv);
|
||||
area.appendChild(header);
|
||||
// #115 — pinned action bar (direct child of the scroll container) so it
|
||||
// stays visible while long documents scroll, as in the main viewer.
|
||||
const toolbar = document.createElement("div");
|
||||
toolbar.className = "file-toolbar";
|
||||
toolbar.appendChild(actionsDiv);
|
||||
area.appendChild(toolbar);
|
||||
|
||||
// Frontmatter — Accent Card
|
||||
if (data.frontmatter && Object.keys(data.frontmatter).length > 0) {
|
||||
|
||||
@@ -1767,7 +1767,22 @@ select {
|
||||
|
||||
/* File header */
|
||||
.file-header {
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
/* #115 — sticky document action bar: stays visible while long content scrolls.
|
||||
A direct child of `.content-area` (the scroll container), opaque so the text
|
||||
scrolling underneath never bleeds through. */
|
||||
.file-toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
margin: 0 0 16px;
|
||||
padding: 8px 0;
|
||||
background: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.file-toolbar .file-actions {
|
||||
margin-top: 0;
|
||||
}
|
||||
.file-title {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
@@ -2670,6 +2685,32 @@ select {
|
||||
.profile-avatar-remove-btn { background: var(--danger-bg) !important; color: var(--danger) !important; border-color: var(--danger) !important; }
|
||||
.profile-avatar-error { font-size: 0.7rem; color: var(--danger); margin-top: 8px; }
|
||||
.profile-avatar-error.hidden { display: none; }
|
||||
/* #117 — preset avatar gallery */
|
||||
.profile-avatar-presets {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
max-width: 420px;
|
||||
}
|
||||
.profile-avatar-preset {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background: var(--bg-secondary);
|
||||
aspect-ratio: 1;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease;
|
||||
}
|
||||
.profile-avatar-preset img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.profile-avatar-preset:hover { border-color: var(--accent); transform: translateY(-1px); }
|
||||
.profile-avatar-preset.active {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
}
|
||||
.profile-avatar-preset:disabled { opacity: 0.6; cursor: progress; }
|
||||
.config-save-btn {
|
||||
padding: 6px 18px; font-size: 0.75rem; font-weight: 600;
|
||||
background: var(--accent); color: #fff;
|
||||
@@ -4920,6 +4961,11 @@ body.resizing-v {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
/* Preset avatars: 4 per row on narrow screens (44px touch targets). */
|
||||
#config-modal .profile-avatar-presets {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
max-width: 100%;
|
||||
}
|
||||
#config-modal .profile-form {
|
||||
max-width: 100%;
|
||||
}
|
||||
@@ -11853,6 +11899,7 @@ body.reading-mode .main-body {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
body.reading-mode .breadcrumb,
|
||||
body.reading-mode .file-toolbar,
|
||||
body.reading-mode .file-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is
|
||||
* a separate store. Bumping SW_VERSION invalidates it on every release.
|
||||
*/
|
||||
const SW_VERSION = 'v25';
|
||||
const SW_VERSION = 'v26';
|
||||
const CODE_CACHE = `obsigate-code-${SW_VERSION}`;
|
||||
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
|
||||
const API_CACHE = `obsigate-api-${SW_VERSION}`;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.24.0",
|
||||
"version": "2.25.0",
|
||||
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
||||
"main": "patch.js",
|
||||
"directories": {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
*/
|
||||
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -136,11 +136,46 @@ test("style.css — preview circle and sidebar image rules exist", () => {
|
||||
assert.match(css, /\.sidebar-user-avatar-img\s*\{/, "no .sidebar-user-avatar-img rule");
|
||||
});
|
||||
|
||||
// ── #117: preset avatar gallery ─────────────────────────────────────────────
|
||||
test("index.html — #cfg-profile exposes 12 bundled preset avatars", () => {
|
||||
const section = indexHtml.match(/<section[^>]*id="cfg-profile"[\s\S]*?<\/section>/);
|
||||
assert.ok(section, "#cfg-profile section not found");
|
||||
const html = section[0];
|
||||
assert.match(html, /id="profile-avatar-presets"/, "preset gallery container missing");
|
||||
const buttons = [...html.matchAll(/class="profile-avatar-preset"[^>]*data-avatar="([^"]+)"/g)];
|
||||
assert.equal(buttons.length, 12, `expected 12 preset avatars, found ${buttons.length}`);
|
||||
const names = buttons.map((m) => m[1]);
|
||||
assert.equal(new Set(names).size, 12, "preset avatar names must be unique");
|
||||
for (const name of names) {
|
||||
const rel = path.join(ROOT, "frontend", "icons", "avatar", name);
|
||||
assert.ok(existsSync(rel), `missing avatar file: ${rel}`);
|
||||
assert.ok(html.includes(`/static/icons/avatar/${name}`),
|
||||
`#${name} must be served from /static/icons/avatar/`);
|
||||
}
|
||||
});
|
||||
|
||||
test("config.js — preset avatars reuse the upload pipeline", () => {
|
||||
assert.match(configJs, /profile-avatar-presets/, "preset container lookup missing");
|
||||
assert.match(configJs, /\.profile-avatar-preset/, "preset button selector missing");
|
||||
assert.match(configJs, /\/static\/icons\/avatar\//, "preset image base URL missing");
|
||||
assert.match(configJs, /_resizeAvatarFile\(blob\)/, "preset must go through the resize pipeline");
|
||||
assert.match(configJs, /_patchProfileAvatar\(dataUrl\)/, "preset must be persisted via PATCH");
|
||||
assert.match(configJs, /obsigate-avatar-preset/, "active preset marker missing");
|
||||
assert.match(configJs, /markActivePreset/, "active preset highlight missing");
|
||||
});
|
||||
|
||||
test("style.css — preset avatar rules exist", () => {
|
||||
assert.match(css, /\.profile-avatar-presets\s*\{/, "no .profile-avatar-presets rule");
|
||||
assert.match(css, /\.profile-avatar-preset\s*\{/, "no .profile-avatar-preset rule");
|
||||
assert.match(css, /\.profile-avatar-preset\.active\s*\{/, "no active preset rule");
|
||||
});
|
||||
|
||||
// ── #113c: i18n + backend ───────────────────────────────────────────────────
|
||||
test("i18n — avatar keys exist in FR and EN", () => {
|
||||
const keys = [
|
||||
"config.avatar_label",
|
||||
"config.avatar_hint",
|
||||
"config.avatar_presets_label",
|
||||
"config.avatar_choose",
|
||||
"config.avatar_remove",
|
||||
"config.avatar_updated",
|
||||
|
||||
@@ -111,6 +111,30 @@ test("style.css — .action-sep rule exists", () => {
|
||||
assert.match(css, /\.file-actions \.action-sep\s*\{[^}]*background:[^;]*;/);
|
||||
});
|
||||
|
||||
// ── #115 : barre d'outils collante au défilement ─────────────────────────
|
||||
test("viewer.js — action bar wrapped in a sticky .file-toolbar", () => {
|
||||
assert.match(viewer, /class:\s*"file-toolbar"[\s\S]{0,120}file-actions/,
|
||||
"the document action bar must live in a .file-toolbar wrapper");
|
||||
});
|
||||
|
||||
test("popout.html — action bar wrapped in a .file-toolbar", () => {
|
||||
assert.match(popout, /className\s*=\s*"file-toolbar"/,
|
||||
"popout action bar must live in a .file-toolbar wrapper");
|
||||
});
|
||||
|
||||
test("style.css — .file-toolbar is pinned to the top while scrolling", () => {
|
||||
const m = css.match(/\.file-toolbar\s*\{([\s\S]*?)\}/);
|
||||
assert.ok(m, ".file-toolbar rule missing");
|
||||
assert.match(m[1], /position:\s*sticky/, ".file-toolbar must be sticky");
|
||||
assert.match(m[1], /top:\s*0/, ".file-toolbar must pin to the top");
|
||||
assert.match(m[1], /background:/, ".file-toolbar needs an opaque background");
|
||||
});
|
||||
|
||||
test("style.css — reading mode hides the sticky toolbar", () => {
|
||||
assert.match(css, /body\.reading-mode\s+\.file-toolbar\s*[,{]/,
|
||||
"reading mode must hide .file-toolbar");
|
||||
});
|
||||
|
||||
if (process.exitCode) {
|
||||
console.error("\nToolbar order tests FAILED");
|
||||
} else {
|
||||
|
||||
@@ -284,6 +284,28 @@ function testSidebarUserSection() {
|
||||
console.log(' ✓ header trimmed + sidebar account section (#112)');
|
||||
}
|
||||
|
||||
// ── Test syntax highlighting follows the theme mode (BUG-078) ──────────────
|
||||
function testSyntaxHighlightTheme() {
|
||||
const themes = readFileSync(join(JS_DIR, 'themes.js'), 'utf-8');
|
||||
const ui = readFileSync(join(JS_DIR, 'ui.js'), 'utf-8');
|
||||
|
||||
// themes.js must swap the highlight.js stylesheets based on the *mode*.
|
||||
const applyTheme = themes.match(/function applyTheme\(themeKey, mode\)\s*\{([\s\S]*?)\n\}/);
|
||||
assert.ok(applyTheme, 'themes.js applyTheme not found');
|
||||
assert.match(applyTheme[1], /hljs-theme-dark/, 'applyTheme must toggle the dark hljs sheet');
|
||||
assert.match(applyTheme[1], /hljs-theme-light/, 'applyTheme must toggle the light hljs sheet');
|
||||
assert.match(applyTheme[1], /mode === 'dark'|mode === "dark"/, 'sheet choice must key on the mode');
|
||||
|
||||
// ui.js must resolve the persisted *mode*, never treat the theme key as one.
|
||||
assert.match(ui, /obsigate-theme-mode/, 'ui.js must read the persisted theme mode');
|
||||
assert.doesNotMatch(
|
||||
ui,
|
||||
/darkSheet\.disabled\s*=\s*theme !== "dark"[\s\S]{0,120}lightSheet\.disabled\s*=\s*theme !== "light"/,
|
||||
'ui.js must not compare the theme key to "dark"/"light" (BUG-078 regression)',
|
||||
);
|
||||
console.log(' ✓ syntax highlighting follows the theme mode (BUG-078)');
|
||||
}
|
||||
|
||||
// ── Run all tests ──────────────────────────────────────────────────────────
|
||||
async function main() {
|
||||
let passed = 0, failed = 0;
|
||||
@@ -300,6 +322,7 @@ async function main() {
|
||||
['ai-fab module exports', testAIFabModuleExports],
|
||||
['config TOC icons', testConfigTocIcons],
|
||||
['sidebar user section', testSidebarUserSection],
|
||||
['syntax highlight theme', testSyntaxHighlightTheme],
|
||||
];
|
||||
|
||||
for (const [name, fn] of tests) {
|
||||
|
||||