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. -[]() +[]() [](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* 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. -[]() +[]() [](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* 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 `
…`) 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) {