diff --git a/CHANGELOG.md b/CHANGELOG.md index 147e4a9..6932191 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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é diff --git a/README.fr.md b/README.fr.md index 97fc8e8..cde48b0 100644 --- a/README.fr.md +++ b/README.fr.md @@ -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. -[![Version](https://img.shields.io/badge/Version-2.24.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.25.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](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* diff --git a/README.md b/README.md index c4c3b94..4827147 100644 --- a/README.md +++ b/README.md @@ -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. -[![Version](https://img.shields.io/badge/Version-2.24.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.25.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](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* diff --git a/VERSION b/VERSION index ad22619..5c18f91 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.24.0 +2.25.0 diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 9cb8eda..242b819 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.24.0" +version = "2.25.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 5d2fe36..95829ac 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -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" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index 5d99883..24498f7 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -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", diff --git a/docs/ISSUES_TODOLIST.md b/docs/ISSUES_TODOLIST.md index 397c87b..f4a21e2 100644 --- a/docs/ISSUES_TODOLIST.md +++ b/docs/ISSUES_TODOLIST.md @@ -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` (`` orphelin supprimé), id dupliqué `cfg-partages-publics` retiré du `

`, `#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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a205134..99282a3 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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) | --- diff --git a/docs/features/viewer-toolbar-highlight-avatars.md b/docs/features/viewer-toolbar-highlight-avatars.md new file mode 100644 index 0000000..2622567 --- /dev/null +++ b/docs/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 (`
…`) 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/.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).
diff --git a/frontend/icons/avatar/Chat.jpg b/frontend/icons/avatar/Chat.jpg
new file mode 100644
index 0000000..27bc07f
Binary files /dev/null and b/frontend/icons/avatar/Chat.jpg differ
diff --git a/frontend/icons/avatar/chien.jpg b/frontend/icons/avatar/chien.jpg
new file mode 100644
index 0000000..c45d46f
Binary files /dev/null and b/frontend/icons/avatar/chien.jpg differ
diff --git a/frontend/icons/avatar/elephan.jpg b/frontend/icons/avatar/elephan.jpg
new file mode 100644
index 0000000..0e0e299
Binary files /dev/null and b/frontend/icons/avatar/elephan.jpg differ
diff --git a/frontend/icons/avatar/hibou.jpg b/frontend/icons/avatar/hibou.jpg
new file mode 100644
index 0000000..1f84d75
Binary files /dev/null and b/frontend/icons/avatar/hibou.jpg differ
diff --git a/frontend/icons/avatar/koala.jpg b/frontend/icons/avatar/koala.jpg
new file mode 100644
index 0000000..57c3eed
Binary files /dev/null and b/frontend/icons/avatar/koala.jpg differ
diff --git a/frontend/icons/avatar/lapin.jpg b/frontend/icons/avatar/lapin.jpg
new file mode 100644
index 0000000..5982649
Binary files /dev/null and b/frontend/icons/avatar/lapin.jpg differ
diff --git a/frontend/icons/avatar/lion.jpg b/frontend/icons/avatar/lion.jpg
new file mode 100644
index 0000000..c80bd62
Binary files /dev/null and b/frontend/icons/avatar/lion.jpg differ
diff --git a/frontend/icons/avatar/ours.jpg b/frontend/icons/avatar/ours.jpg
new file mode 100644
index 0000000..9589693
Binary files /dev/null and b/frontend/icons/avatar/ours.jpg differ
diff --git a/frontend/icons/avatar/penda.jpg b/frontend/icons/avatar/penda.jpg
new file mode 100644
index 0000000..8b6dcf5
Binary files /dev/null and b/frontend/icons/avatar/penda.jpg differ
diff --git a/frontend/icons/avatar/pingouin.jpg b/frontend/icons/avatar/pingouin.jpg
new file mode 100644
index 0000000..e91bf9f
Binary files /dev/null and b/frontend/icons/avatar/pingouin.jpg differ
diff --git a/frontend/icons/avatar/raton.jpg b/frontend/icons/avatar/raton.jpg
new file mode 100644
index 0000000..386db8d
Binary files /dev/null and b/frontend/icons/avatar/raton.jpg differ
diff --git a/frontend/icons/avatar/tigre.jpg b/frontend/icons/avatar/tigre.jpg
new file mode 100644
index 0000000..8978f0f
Binary files /dev/null and b/frontend/icons/avatar/tigre.jpg differ
diff --git a/frontend/index.html b/frontend/index.html
index be3ab6a..d0b1558 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1660,6 +1660,27 @@
                                                 PNG, JPG ou WEBP — image carrée recadrée et réduite à 256 px.
                                             
                                         
+                                        Avatars prédéfinis
+                                        
+ + + + + + + + + + + + +
diff --git a/frontend/js/config.js b/frontend/js/config.js index 46c2e85..b49a0cc 100644 --- a/frontend/js/config.js +++ b/frontend/js/config.js @@ -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'); diff --git a/frontend/js/themes.js b/frontend/js/themes.js index 3874ec1..c19f9a0 100644 --- a/frontend/js/themes.js +++ b/frontend/js/themes.js @@ -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) {} } diff --git a/frontend/js/ui.js b/frontend/js/ui.js index f3ed19c..743c5c7 100644 --- a/frontend/js/ui.js +++ b/frontend/js/ui.js @@ -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 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() {}); } diff --git a/frontend/js/viewer.js b/frontend/js/viewer.js index efa4505..4368dce 100644 --- a/frontend/js/viewer.js +++ b/frontend/js/viewer.js @@ -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); diff --git a/frontend/locales/en.json b/frontend/locales/en.json index c5d65a0..8c9b7e5 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -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", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 090ca4a..f4fd09c 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -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", diff --git a/frontend/popout.html b/frontend/popout.html index 170ec64..23eea4b 100644 --- a/frontend/popout.html +++ b/frontend/popout.html @@ -38,7 +38,7 @@ } })(); - + @@ -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) { diff --git a/frontend/style.css b/frontend/style.css index 8f01b43..b16eebe 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -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; } diff --git a/frontend/sw.js b/frontend/sw.js index 8cd01bf..84bfec9 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -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}`; diff --git a/package.json b/package.json index 7b5203d..49b8549 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/tests/frontend/settings-order-avatar.test.mjs b/tests/frontend/settings-order-avatar.test.mjs index 2585295..cfec32e 100644 --- a/tests/frontend/settings-order-avatar.test.mjs +++ b/tests/frontend/settings-order-avatar.test.mjs @@ -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(/]*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", diff --git a/tests/frontend/toolbar-order.test.mjs b/tests/frontend/toolbar-order.test.mjs index a50000d..83e835a 100644 --- a/tests/frontend/toolbar-order.test.mjs +++ b/tests/frontend/toolbar-order.test.mjs @@ -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 { diff --git a/tests/frontend/unit.test.mjs b/tests/frontend/unit.test.mjs index b5e284b..dd0e85c 100644 --- a/tests/frontend/unit.test.mjs +++ b/tests/frontend/unit.test.mjs @@ -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) {