diff --git a/README.md b/README.md index da9efb7..5793894 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,11 @@ Pas besoin de Python, de venv ni de terminal. Les [releases GitHub](https://github.com/SKOHscripts/Kairos/releases) proposent un exécutable autonome par OS (`kairos-linux-x86_64`, `kairos-windows-x86_64.exe`) et un APK Android (`kairos-android-arm64.apk`). Télécharge, double-clique (sous Linux, rends d'abord le -fichier exécutable avec `chmod +x kairos-linux-x86_64`), et le navigateur s'ouvre tout -seul sur Kairos. Les réglages et la base de tâches vivent dans le dossier de données +fichier exécutable avec `chmod +x kairos-linux-x86_64`), et une fenêtre s'ouvre toute +seule sur Kairos — sans barre d'adresse ni onglets, le ressenti d'une vraie application +de bureau (si un navigateur de la famille Chromium — Chrome, Edge, Brave, Vivaldi... — +est installé ; sinon repli automatique sur un onglet du navigateur par défaut, sans rien +à configurer). Les réglages et la base de tâches vivent dans le dossier de données standard de ton système, entièrement éditables depuis la page **Réglages**. Aucun fichier `.env` à copier ou à éditer à la main. @@ -108,6 +111,17 @@ make test # venv + suite de tests complète ## Fonctionnalités +### Notes (capture GTD) +Une page dédiée (`/kairos/notes`, entre Accueil et Jour dans la navigation) pour se +décharger l'esprit sans réfléchir à la structure : une seule zone de texte libre, aucune +priorité ni échéance à choisir sur le moment (Ctrl/Cmd+Entrée pour capturer sans lâcher +le clavier). Chaque note capturée apparaît immédiatement dans la liste, sans rechargement +de page. Une fois qu'une idée est prête à devenir actionnable, un clic sur **« → Tâche »** +la convertit en tâche titre-seul, qui atterrit directement dans la boîte de réception « +À traiter » de la vue Jour — la note d'origine est archivée (jamais supprimée) avec un +lien vers la tâche créée. Une note peut aussi être éditée sur place ou classée sans suite +(archivée) si elle ne mène nulle part. + ### Gestion des tâches - **Création rapide** en une ligne (le titre seul suffit). Édition complète ensuite : titre, description, priorité 0-2 (P0 = la plus forte), échéance, date programmée, diff --git a/app/desktop_browser.py b/app/desktop_browser.py new file mode 100644 index 0000000..66e4397 --- /dev/null +++ b/app/desktop_browser.py @@ -0,0 +1,153 @@ +"""Détection d'un navigateur Chromium et ouverture en « fenêtre d'application ». + +`webbrowser.open` (utilisé par défaut dans `app/launcher.py`) ouvre un onglet +dans le navigateur système par défaut — barre d'adresse, onglets, tout +l'attirail d'un navigateur généraliste, alors que Kairos se veut ressenti +comme une application de bureau à part entière (voir `docs/spec/ +packaging-lancement.md`). Les navigateurs de la famille Chromium (Chrome, +Chromium, Edge, Brave, Vivaldi — tous basés sur le même moteur et acceptant +les mêmes indicateurs de ligne de commande) savent s'ouvrir en **fenêtre +d'application** via `--app=URL` : pas de barre d'adresse, pas d'onglets, un +ressenti de « web app installée ». Firefox et Safari n'ont pas d'équivalent +strict à cet indicateur — ce module ne cible donc que la famille Chromium. + +Ce module reste volontairement séparé de `app/launcher.py` : sa logique +(détection d'un binaire, construction des arguments) est pure et se teste +sans toucher à uvicorn, aux threads ou au fichier de verrou. `app/launcher.py` +l'appelle depuis `_open_browser`, avec un repli automatique et silencieux vers +`webbrowser.open` si la détection ou le lancement échoue — voir le +commentaire à l'appel pour le détail de cette décision. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys + +from app.settings_store import data_dir +from app.subprocess_env import external_process_env + +# Ordre de préférence indicatif seulement (le premier trouvé gagne) — pas de +# hiérarchie qualitative entre ces navigateurs, juste une liste stable pour +# un comportement déterministe d'un poste à l'autre. +_LINUX_BROWSER_NAMES = ( + "google-chrome-stable", + "google-chrome", + "chromium-browser", + "chromium", + "brave-browser", + "microsoft-edge", + "microsoft-edge-stable", + "vivaldi-stable", + "vivaldi", +) + +# Chemins relatifs sous chacun des dossiers de base Windows testés (l'ordre des +# bases ci-dessous est : Program Files, Program Files (x86), puis LocalAppData +# — un même navigateur peut atterrir sous l'une ou l'autre selon qu'il a été +# installé pour tous les utilisateurs ou seulement l'utilisateur courant ; on +# ne présume pas laquelle pour ne pas rater une installation valide). +_WINDOWS_BROWSER_RELATIVE_PATHS = ( + r"Google\Chrome\Application\chrome.exe", + r"Microsoft\Edge\Application\msedge.exe", + r"BraveSoftware\Brave-Browser\Application\brave.exe", + r"Vivaldi\Application\vivaldi.exe", +) + +_BROWSER_PROFILE_DIRNAME = "browser-profile" + + +def _windows_base_dirs() -> list[str]: + bases = [] + for env_var in ("ProgramFiles", "ProgramFiles(x86)", "LocalAppData"): + base = os.environ.get(env_var) + if base: + bases.append(base) + return bases + + +def find_app_capable_browser() -> str | None: + """Cherche un navigateur de la famille Chromium installé sur ce poste. + + Fonction pure (aucun effet de bord, pas d'impression) : ne fait que des + vérifications sur le système de fichiers / l'environnement, pour rester + facilement testable en monkeypatchant `shutil.which`, `os.environ` et + `os.path.exists`. + """ + # `KAIROS_BROWSER` : échappatoire explicite pour les tests/CI (imposer un + # binaire précis sans dépendre de ce qui est réellement installé), et pour + # un utilisateur avancé qui voudrait forcer un navigateur particulier — + # prioritaire sur toute détection automatique. + override = os.environ.get("KAIROS_BROWSER") + if override: + if os.path.isfile(override) and os.access(override, os.X_OK): + return override + resolved = shutil.which(override) + if resolved: + return resolved + return None + + if sys.platform == "linux": + for name in _LINUX_BROWSER_NAMES: + found = shutil.which(name) + if found: + return found + return None + + if sys.platform == "win32": + base_dirs = _windows_base_dirs() + for relative_path in _WINDOWS_BROWSER_RELATIVE_PATHS: + for base_dir in base_dirs: + candidate = os.path.join(base_dir, relative_path) + if os.path.isfile(candidate): + return candidate + return None + + # macOS (et tout autre OS) : hors périmètre de Kairos, voir + # `docs/spec/packaging-lancement.md` § Hors périmètre. Pas de détection + # dédiée, repli automatique vers `webbrowser.open` côté appelant. + return None + + +def launch_app_window(browser_path: str, url: str) -> bool: + """Lance ``browser_path`` en fenêtre d'application sur ``url``. + + Retourne `True` si le processus a bien été lancé (pas de garantie que la + fenêtre s'affiche effectivement — fonctionnalité de confort, jamais + bloquante), `False` sur tout échec. + """ + # Profil dédié, séparé du profil personnel de l'utilisateur : la fenêtre + # d'application ne doit pas se mêler à ses onglets/extensions/sessions du + # navigateur habituel, et un profil Chromium ne peut de toute façon pas + # être ouvert deux fois simultanément par deux processus distincts. + profile_dir = str(data_dir() / _BROWSER_PROFILE_DIRNAME) + argv = [browser_path, f"--user-data-dir={profile_dir}", f"--app={url}"] + + kwargs: dict = {} + if sys.platform == "win32" and hasattr(subprocess, "DETACHED_PROCESS"): + kwargs["creationflags"] = subprocess.DETACHED_PROCESS + + try: + # `external_process_env()` (pas la version context manager, + # `Popen` accepte un `env=` explicite) : évite qu'un navigateur lancé + # depuis l'exécutable PyInstaller onefile hérite du `LD_LIBRARY_PATH` + # détourné vers les bibliothèques embarquées — voir + # `app/subprocess_env.py`. + subprocess.Popen( + argv, + env=external_process_env(), + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + **kwargs, + ) + except Exception: + # Fonctionnalité de confort en arrière-plan : un binaire manquant + # malgré la détection, un droit refusé, ou toute autre surprise ne + # doit jamais faire planter ni bloquer le lancement de Kairos — + # l'appelant retombe sur `webbrowser.open`. + return False + return True diff --git a/app/launcher.py b/app/launcher.py index eb16d91..2a5ae28 100644 --- a/app/launcher.py +++ b/app/launcher.py @@ -43,6 +43,7 @@ # fonctionnent dans les deux cas (script figé et `pip install -e .`), tant que # la racine du dépôt est sur `sys.path` (c'est le cas ici : `pathex` du spec, # ou le `.pth` du mode editable). +from app.desktop_browser import find_app_capable_browser, launch_app_window from app.main import app from app.settings_store import data_dir from app.subprocess_env import external_process_environ @@ -71,6 +72,16 @@ def _open_browser(url: str) -> None: # (processus fantôme sur un runner CI, effets de bord imprévisibles). if os.environ.get("KAIROS_NO_BROWSER"): return + # Fenêtre d'application (Chromium `--app=URL`, voir `app/desktop_browser.py`) + # imposée par défaut, sans réglage utilisateur : c'est le ressenti recherché + # pour l'exécutable de bureau (pas un onglet de navigateur généraliste), et + # ça se dégrade tout seul vers l'ancien comportement (onglet du navigateur + # par défaut) si aucun navigateur Chromium n'est trouvé ou si son lancement + # échoue pour n'importe quelle raison — jamais d'erreur remontée à + # l'utilisateur pour cette fonctionnalité de confort. + browser_path = find_app_capable_browser() + if browser_path and launch_app_window(browser_path, url): + return # `external_process_environ()` : voir `app/subprocess_env.py` — évite # qu'un navigateur/`xdg-open` lancé par PyInstaller (mode onefile) hérite # du `LD_LIBRARY_PATH` détourné vers ses bibliothèques embarquées. diff --git a/app/main.py b/app/main.py index 8741334..e36b215 100644 --- a/app/main.py +++ b/app/main.py @@ -49,6 +49,7 @@ ) from .tasks_models import ( FIBONACCI_SCALE, + Note, Task, TaskDependency, TimeBlock, @@ -1292,3 +1293,143 @@ def delete_manual_block(request: Request) -> RedirectResponse: tasks_session.commit() return RedirectResponse("/kairos", status_code=303) + +# -------------------------------------------------------------------------- +# Notes (capture GTD, en amont de l'inbox de la vue Jour) — voir +# docs/spec/notes-capture.md. Même patron de rendu que « Kairos » (fragment +# AJAX négocié sur `X-Requested-With: fetch`), mirroré ici avec son propre +# contenu (`_notes_list.html`, id `#mj-notes-content`) plutôt que réutilisé. +# -------------------------------------------------------------------------- + + +def _build_notes_context(request: Request, tasks_session: Session) -> dict: + """Contexte de rendu de la page Notes : notes ouvertes (capture active) et + notes archivées (converties ou classées sans suite), les deux triées de la + plus récente à la plus ancienne — symétrique de `_build_kairos_context`.""" + open_notes = list( + tasks_session.scalars( + select(Note).where(Note.status == "open").order_by(Note.created_at.desc()) + ) + ) + archived_notes = list( + tasks_session.scalars( + select(Note).where(Note.status == "archived").order_by(Note.created_at.desc()) + ) + ) + return { + "page": "notes", + "open_notes": open_notes, + "archived_notes": archived_notes, + } + + +def render_notes_response(request: Request, *, fragment: bool) -> Response: + """Point d'entrée unique de rendu de la page Notes, même contrat que + `render_kairos_response` : `fragment=False` rend la page pleine + (`notes.html`), `fragment=True` rend seulement `_notes_list.html` + (id `#mj-notes-content`), utilisé par les handlers d'action pour + l'amélioration progressive AJAX. Les handlers doivent avoir committé et + fermé leur propre session avant d'appeler cette fonction, qui rouvre une + session fraîche — jamais de session imbriquée (même invariant que Kairos).""" + with _request_session(get_tasks_session) as tasks_session: + context = _build_notes_context(request, tasks_session) + template_name = "_notes_list.html" if fragment else "notes.html" + return templates.TemplateResponse(request, template_name, context) + + +@app.get("/kairos/notes") +def notes_page(request: Request) -> HTMLResponse: + return render_notes_response(request, fragment=False) + + +def _notes_action_response(request: Request) -> Response: + """Réponse commune des handlers d'action Notes : mêmes règles que + `_kairos_action_response` (fragment AJAX si `X-Requested-With: fetch`, + sinon redirection 303 complète — repli sans JS pour la WebView Android et + l'accessibilité).""" + if request.headers.get("X-Requested-With") == "fetch": + return render_notes_response(request, fragment=True) + return RedirectResponse("/kairos/notes", status_code=303) + + +@app.post("/kairos/notes") +async def create_note(request: Request) -> Response: + """Capture rapide : un corps de texte libre, rien d'autre — pas de priorité, + pas de points, pas d'échéance (c'est tout l'intérêt par rapport à la boîte + de réception de la vue Jour, qui porte déjà ces champs sur `Task`).""" + form = await request.form() + body = str(form.get("body", "")).strip() + with _request_session(get_tasks_session) as tasks_session: + if body: + tasks_session.add(Note(body=body)) + tasks_session.commit() + return _notes_action_response(request) + + +@app.post("/kairos/notes/{note_id:int}/edit") +async def edit_note(request: Request) -> Response: + """Édite le corps d'une note existante. Note disparue entre-temps → no-op + silencieux (même tolérance que les handlers d'action de la vue Jour).""" + form = await request.form() + with _request_session(get_tasks_session) as tasks_session: + note = tasks_session.get(Note, request.path_params["note_id"]) + if note is not None: + note.body = str(form.get("body", "")).strip() + tasks_session.commit() + return _notes_action_response(request) + + +def _note_title_from_body(body: str) -> str: + """Titre de la tâche créée par conversion : première ligne non vide du corps + de la note, tronquée à 200 caractères (cohérent avec `Task.title`, + `String(512)`, mais une capture rapide n'a pas besoin d'approcher cette + limite).""" + first_line = next((line.strip() for line in body.splitlines() if line.strip()), "") + return first_line[:200] + + +@app.post("/kairos/notes/{note_id:int}/convert") +def convert_note_to_task(request: Request) -> Response: + """Le moment clé du flux : la note devient une tâche titre-seul (elle atterrit + dans l'inbox « À traiter » de la vue Jour, à qualifier comme n'importe quelle + autre capture), la note elle-même est **archivée et liée**, jamais supprimée — + préserve l'historique de la capture d'origine (voir `Note.converted_task_id`, + sans contrainte FK, cohérent avec le reste du schéma).""" + with _request_session(get_tasks_session) as tasks_session: + note = tasks_session.get(Note, request.path_params["note_id"]) + if note is not None and note.status == "open": + title = _note_title_from_body(note.body) + if title: + task = Task(title=title, source="native") + tasks_session.add(task) + tasks_session.flush() # attribue l'id avant de le référencer + note.status = "archived" + note.converted_task_id = task.id + tasks_session.commit() + return _notes_action_response(request) + + +@app.post("/kairos/notes/{note_id:int}/archive") +def archive_note(request: Request) -> Response: + """Classe une note sans suite : retirée de la liste de capture active, jamais + supprimée (même sémantique que la conversion, sans `converted_task_id`).""" + with _request_session(get_tasks_session) as tasks_session: + note = tasks_session.get(Note, request.path_params["note_id"]) + if note is not None: + note.status = "archived" + tasks_session.commit() + return _notes_action_response(request) + + +@app.post("/kairos/notes/{note_id:int}/delete") +def delete_note(request: Request) -> Response: + """Suppression définitive — à la différence de l'archivage, retire la ligne en + base (une note n'a pas d'historique de priorisation à préserver, contrairement + à une tâche).""" + with _request_session(get_tasks_session) as tasks_session: + note = tasks_session.get(Note, request.path_params["note_id"]) + if note is not None: + tasks_session.delete(note) + tasks_session.commit() + return _notes_action_response(request) + diff --git a/app/tasks_models.py b/app/tasks_models.py index 42a034c..1925ce7 100644 --- a/app/tasks_models.py +++ b/app/tasks_models.py @@ -185,6 +185,35 @@ class WorkSession(TasksBase): created_at: Mapped[datetime] = mapped_column(DateTime, default=_now) +class Note(TasksBase): + """Note libre (« brain dump » GTD), capturée sans friction avant qualification. + + Étape de **capture** en amont de la boîte de réception de la vue Jour (qui ne + reçoit que des `Task` titre-seul, avec priorité/points à poser) : une note n'a + ni priorité, ni points, ni échéance — juste un corps de texte libre, posé le + plus vite possible, décidé plus tard. `converted_task_id` (nullable, **sans + contrainte FK**, même parti pris que `Task.parent_id`/`linked_ticket_id` — voir + docs/spec/modele-donnees.md) trace la tâche créée lors d'une conversion + note → tâche, en lecture seule (aucune synchro retour). `status='archived'` est + la façon dont une note convertie (ou classée sans suite) sort de la liste de + capture active **sans jamais être supprimée** : mêmes principes de non-perte + que `Task.status='archived'`. + """ + + __tablename__ = "note" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + body: Mapped[str] = mapped_column(Text, default="") + # 'open' | 'archived'. + status: Mapped[str] = mapped_column(String(16), default="open", index=True) + # Id local de la tâche créée par conversion, sans contrainte FK — référence + # « molle », cohérente avec le reste du schéma. None tant que la note n'a pas + # été convertie. + converted_task_id: Mapped[int | None] = mapped_column(Integer, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=_now) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=_now, onupdate=_now) + + class TaskSyncMeta(TasksBase): """Méta du dernier fetch réussi par source (mirror de `GitLabRefreshMeta`). diff --git a/app/tasks_seed.py b/app/tasks_seed.py index 062466e..a6edfd9 100644 --- a/app/tasks_seed.py +++ b/app/tasks_seed.py @@ -20,7 +20,7 @@ from sqlalchemy.orm import Session -from .tasks_models import Task, TaskDependency, TimeBlock +from .tasks_models import Note, Task, TaskDependency, TimeBlock EXAMPLE_PROJECT_TAG = "Exemple" @@ -179,3 +179,16 @@ def add_task(**kwargs) -> Task: ] ) session.flush() + + # Note d'exemple (capture GTD) : illustre l'étape en amont de l'inbox « À + # traiter » (page Notes) — une idée jetée sans friction, pas encore une tâche. + session.add( + Note( + body=( + "[Exemple] Idée en vrac : revoir le découpage des sprints ?\n" + "À développer avant d'en faire une tâche — ou à archiver si " + "ça ne mène nulle part." + ) + ) + ) + session.flush() diff --git a/docs/spec/accueil-navigation.md b/docs/spec/accueil-navigation.md index 77d26e4..f8a3a45 100644 --- a/docs/spec/accueil-navigation.md +++ b/docs/spec/accueil-navigation.md @@ -12,7 +12,8 @@ ici que ce qui est commun à toutes les pages (topnav) ou spécifique à l'accue ### Objectif / problème -Kairos comporte plusieurs vues (Accueil, Jour, Semaine, Statistiques, Réglages) qui +Kairos comporte plusieurs vues (Accueil, Notes, Jour, Semaine, Statistiques, +Réglages) qui doivent partager une identité visuelle et une navigation cohérentes, sans dupliquer le HTML de la barre de navigation dans chaque template. Il faut aussi une page d'accueil qui explique ce que fait l'outil à un nouvel utilisateur (collègue @@ -34,9 +35,11 @@ est pénible sur une longue liste. - Une barre de navigation horizontale, fixe en haut de l'écran (sticky), présente sur toutes les pages : logo/nom Kairos (lien vers l'accueil), puis les entrées Accueil, - Jour, Semaine, Statistiques, Réglages. L'entrée correspondant à la page affichée - est mise en évidence. -- **Exception, APK Android uniquement** : les cinq entrées sont déplacées vers une + Notes, Jour, Semaine, Statistiques, Réglages. L'entrée correspondant à la page + affichée est mise en évidence. « Notes » est placée entre Accueil et Jour : la + capture (page Notes) précède la triage/exécution (vue Jour) dans le flux GTD — + voir `docs/spec/notes-capture.md`. +- **Exception, APK Android uniquement** : les six entrées sont déplacées vers une barre de navigation basse fixe (icône + libellé, cible tactile ≥ 44px), le logo Kairos restant seul dans la barre du haut. Cette bottom nav n'apparaît **jamais** sur un navigateur (dev, service, exécutable de bureau), quelle que soit la largeur @@ -93,6 +96,9 @@ est pénible sur une longue liste. au profit de la topnav horizontale. - Contenu détaillé de la vue Jour/GTD (filtres, backlog, progression du jour...) : `docs/spec/vue-jour-gtd.md`. +- Contenu détaillé de la page Notes (capture, conversion en tâche, archivage) : + `docs/spec/notes-capture.md` — cette spec ne couvre que l'entrée de navigation + elle-même (icône, position, condition `active`). - Authentification/comptes multiples : Kairos reste mono-utilisateur, la navigation n'a pas de notion de session utilisateur. @@ -115,9 +121,17 @@ dans le template via le contexte de la route `/` (`app/main.py::home`). #### `templates/base.html` — gabarit commun - **`
`** : titre par bloc (`{% block title %}Kairos{% endblock %}`), favicon - SVG (`/static/favicon.svg?v={{ asset_version }}`), polices Google Fonts (IBM Plex - Sans 400/500/600/700 + Newsreader italique 500, voir `docs/DESIGN_SYSTEM.md`), - feuille de style unique `/static/style.css?v={{ asset_version }}`. Le suffixe + SVG (`/static/favicon.svg?v={{ asset_version }}`), manifest PWA + (``) et icônes PNG + (192/512, `apple-touch-icon`, `?v={{ asset_version }}` sur les trois comme sur + `style.css`) + `` — le contenu de ces + fichiers `static/` eux-mêmes (manifest, PNG) est hors périmètre de ce document + (propriété/contenu d'un autre chantier), seules les balises ``/`` de + `base.html` y sont couvertes ; le favicon SVG existant reste inchangé en plus de + ces icônes PNG (navigateurs qui préfèrent le SVG le gardent). Polices Google Fonts + (IBM Plex Sans 400/500/600/700 + Newsreader italique 500, voir + `docs/DESIGN_SYSTEM.md`), feuille de style unique + `/static/style.css?v={{ asset_version }}`. Le suffixe `?v=` (posé par `templates.env.globals["asset_version"]` dans `app/main.py`, valeur = horodatage de modification de `style.css`, ou `0` si illisible) est un anti-cache navigateur : sans lui, un navigateur peut continuer à servir un vieux @@ -131,17 +145,18 @@ dans le template via le contexte de la route `/` (`app/main.py::home`). couleurs terracotta d'origine conservées volontairement — voir `docs/DESIGN_SYSTEM.md` § Identité) + nom « Kairos » + sous-titre « le bon moment, la bonne tâche » (masqué sous 720px, voir § Invariants). - - `.tn-nav` : cinq entrées (Accueil `/`, Jour `/kairos?view=day`, Semaine - `/kairos?view=week`, Statistiques `/kairos/stats`, Réglages - `/kairos/settings`), chacune avec une icône (`icon('home')`, `icon('clock')`, - `icon('calendar')`, `icon('trending_up')`, `icon('gear')`) et un libellé texte. + - `.tn-nav` : six entrées (Accueil `/`, Notes `/kairos/notes`, Jour + `/kairos?view=day`, Semaine `/kairos?view=week`, Statistiques + `/kairos/stats`, Réglages `/kairos/settings`), chacune avec une icône + (`icon('home')`, `icon('notes')`, `icon('clock')`, `icon('calendar')`, + `icon('trending_up')`, `icon('gear')`) et un libellé texte. - **Mise en évidence de l'entrée active** : classe `active` conditionnée sur les variables de contexte passées par chaque route — `page == 'home'`, - `page == 'kairos' and (view is not defined or view != 'week')` (Jour, y compris - quand `view` n'est pas défini — défaut jour), `page == 'kairos' and view == - 'week'` (Semaine), `page == 'kairos_stats'`, `page == 'settings'`. Ces variables - (`page`, `view`) sont posées par chaque route dans le contexte de rendu, pas - déduites de l'URL côté template. + `page == 'notes'` (Notes), `page == 'kairos' and (view is not defined or view + != 'week')` (Jour, y compris quand `view` n'est pas défini — défaut jour), + `page == 'kairos' and view == 'week'` (Semaine), `page == 'kairos_stats'`, + `page == 'settings'`. Ces variables (`page`, `view`) sont posées par chaque + route dans le contexte de rendu, pas déduites de l'URL côté template. - **Bouton Quitter (`.tn-quit`)** : bloc conditionné par `{% if is_frozen %}`. `is_frozen` est une variable globale Jinja2 posée une fois au chargement du module (`templates.env.globals["is_frozen"] = getattr(sys, "frozen", False)`, @@ -155,7 +170,7 @@ dans le template via le contexte de la route `/` (`app/main.py::home`). is_android %}is-android{% endif %}">` porte la classe `is-android` sur la racine du gabarit ; un second bloc `{% if is_android %}` (dernier enfant de `.layout`, après `