# Changelog - FlowDeck ## v7.58.0 (2026-10-06) — Add plugins : catalogue on/off à effet réel (phase 8/8) ### Added - **`app/services/plugins.py` + migration 35** — table `plugins` (`slug`, `name`, `description`, `enabled`) pré-remplie avec **3 modules câblés** : `web-tools`, `web-clipper`, `automations`. Ligne absente = activé (défaut sûr : rien n'est coupé par accident). - **Effet réel, jamais un simple drapeau** : - `automations` OFF → dépendance FastAPI posée **à l'`include_router`** (3 lignes dans `main.py`, aucun router touché) → toutes les routes `/workspace/automations*` refusées, **et** le scheduler de fond passe en veille (garde sur chaque tick) ; - `web-clipper` OFF → `GET /extensions` + tout `/api/v2/web-clipper/*` refusés ; - `web-tools` OFF → `web_search` et `fetch_url` retirés du **schéma** de l'agent et de `execute()` (`ToolRegistry._all()`) : le LLM ne les voit plus. - **UI masquée côté serveur** — nouveau global Jinja `plugin_enabled(slug)` : `settings.html` rend la nav « Extensions » / « Automations » sous `{% if %}` (élément **absent** du DOM, pas seulement caché) et conditionne les sections en `x-show`. - **Menu +** — l'entrée « Add plugins » devient une vraie section : liste des 3 plugins (état + description) avec bascule ; `GET /api/agent/plugins` + `PATCH /api/agent/plugins/{slug}` (slug inconnu → 404, 401 sans session). - **Tests** — `tests/test_v758_plugins.py` : **10 tests** — catalogue, persistance en base, routes refusées (302 hors `/api` + 404 JSON pour `/api*`, handler unifié de `main.py`), outils retirés du registre, nav disparue du `/settings` rendu, câblage du menu, 401. ## v7.57.0 (2026-10-06) — Connecteurs : Discord, Telegram, Teams, MCP (phase 7/8) ### Added - **Presets Discord / Telegram** — champ **Type** dans le formulaire « Ajouter un connecteur » (Personnalisé / Discord / Telegram / Serveur MCP) : URL et schéma d'authentification pré-remplis. Colonnes `kind` + `auth` (`bearer`/`bot`/`none`) ajoutées à `agent_connectors` (migration **34**) ; Discord envoie son jeton préfixé dans l'en-tête d'autorisation, Telegram place le jeton **dans l'URL** via le substitut `{secret}` — substitué à l'appel, **jamais stocké en clair** (la colonne url ne contient que le motif). - **Teams** — scope `ChannelMessage.Read.All` ajouté aux Graph scopes M365 (consentement admin requis à la reconnexion). - **`app/services/mcp_client.py`** — client MCP (streamable HTTP) : handshake `initialize` → `notifications/initialized` → `tools/list` → `tools/call` (JSON-RPC 2.0, réponse JSON **ou** premier `data:` d'un `text/event-stream`), garde SSRF conservée, erreurs JSON-RPC remontées telles quelles, sortie bornée à 20 000 car. Les outils sont **cachés** en base (`tools_json`) — le bouton « Tester le connecteur » rejoue le handshake et met à jour la liste. - **Outils dynamiques au registre** — `ToolRegistry._all()` merge ce cache à chaque run : chaque outil est exposé au LLM sous `mcp__` avec son `inputSchema` d'origine, et `execute()` dispatche sur `tools/call`. Serveur désactivé → outil **absent du schéma** ; échec réseau → `ToolResult(error)` sans casser le run. - **Menu +** — badge `· N outil(s)` sur la fiche d'un serveur MCP ; `POST /api/agent/connectors` accepte `kind` + `auth` (400 sur valeurs inconnues). - **Tests** — `tests/test_v757_mcp_discord.py` : **12 tests**, 0 appel réseau réel (`_rpc` / `_get` monkeypatchés) — presets, `{secret}` absent de la base, handshake + séquence d'appels, cache, dispatch LLM, serveur désactivé, erreurs RPC, parse SSE, scopes Teams, câblage menu. ## v7.56.0 (2026-10-06) — Connecteurs : Google + Microsoft 365 (phase 6/8) ### Added - **`app/services/oauth_connectors.py`** — flow OAuth2 complet **PKCE (S256)** pour 2 fournisseurs décrits par 1 dict : - **Google** : Drive / Gmail / Calendar **en lecture seule** ; - **Microsoft 365** : Graph `Files.Read` / `Mail.Read` / `Calendars.Read` ; - `begin()` → URL d'autorisation + state + code_verifier ; `complete()` → échange du code ; `access_token()` → **refresh automatique** (60 s avant expiration, `refresh_token` conservé si le fournisseur n'en renvoie pas) ; `api_get()` → lecture bornée, `path` absolu refusé + validation SSRF ; - tokens chiffrés **Fernet** via `_encrypt_tokens` de `calendar_sync` (réutilisation, aucune nouvelle dépendance) dans la table **`connector_tokens`** (migration **33**, PK `(kind, user_id)`). - **4 routes OAuth** (`/api/agent/connectors/oauth/{kind}/…`) : `status`, `authorize` (cookies d'état HttpOnly 10 min + retour same-origin validé), `callback` (GET = SAFE_METHODS, `state` comparé en temps constant, tokens stockés puis cookies purgés, redirection `?oauth=connected` / `?oauth_error=…`), `disconnect`. **OpenAPI : 523 chemins**. - **Config + `.env.example`** : `GOOGLE_CLIENT_ID/SECRET`, `MS_CLIENT_ID/SECRET` (vidés = « non configuré »), redirect URI dérivé d'APP_BASE_URL. - **Catalogue** : les 2 nouveaux natifs apparaissent avec le badge « non connecté — lancer la connexion OAuth » / « connecté · N scope(s) » ; `connector_fetch` et `Tester` passent par l'API Graph/Google avec le token de l'utilisateur (outil LLM = `user_id` désormais transmis). - **Menu +** : « Se connecter » (redirige vers le fournisseur) / « Déconnecter » sur la fiche du connecteur, + toast au retour du flux (URL nettoyée via `history.replaceState`). ### Tests - `tests/test_v756_oauth_connectors.py` — **13 tests** : URL d'autorisation (PKCE, scopes, redirect URI, cookies), 400 non configuré, callback → tokens **chiffrés** en base + redirection + `status.connected`, **state forgé refusé sans aucun appel réseau**, callback sans session → `oauth_error=session`, refresh (grant_type, conservation du refresh_token), `api_get` (Bearer + hôte fixe + URL absolue refusée), non connecté, déconnexion, catalogue, outil LLM, 401 sans session, 404 kind inconnu, câblage menu. Aucun appel réseau réel (`_post_form` / `_api_get` monkeypatchés). - `test_v755` adapté : **5 natifs** (gitea, github, web, google, ms365). - **Suite complète : 1319 passed / 0 failed** (`-n auto`), `ruff` 0, `eslint` 0 problème. ## v7.55.0 (2026-10-06) — Menu + : Connecteurs — socle (phase 5/8) ### Added - **Table `agent_connectors`** (migration **32**) : `name`, `url`, `secret_encrypted`, `enabled`, `status`, `detail` — colonnes **plates**, pas de `config_json` (les scopes OAuth des phases 6-7 ajouteront le leur). - **`app/services/connectors.py`** — 1 fichier (pas de package) : - **3 connecteurs natifs** (`gitea`, `github`, `web`) servis à la volée, statut dérivé de la config **sans réseau** (`native_state()`) ; - CRUD des connecteurs personnalisés : l'URL passe par **`_validate_url` (garde SSRF)** à la création, la clé est **chiffrée Fernet** (`encrypt_secret`) et **jamais renvoyée** (seul `has_secret` l'est) ; - `probe()` : appel de contrôle (`_get`, timeout 10 s, re-vérification de l'hôte après redirection) qui **persiste** `status`/`detail` ; - `connector_fetch()` : lecture pour l'LLM — API Gitea/GitHub (native), recherche web (native) ou GET du connecteur personnalisé, borné à 20 000 car. - **API** (`/api/agent/connectors`) — `GET` (catalogue), `POST` (création, 400 sur URL privée), `PATCH` (name/enabled/secret), `DELETE`, `POST /connectors/probe` ; session requise partout (401), CSRF global. **OpenAPI : 519 chemins**. - **Outil LLM `connector_fetch`** (`tool_registry`, 26ᵉ outil) — args `connector` (id / nom / kind), `path`, `query` ; connecteur désactivé ou inconnu = erreur outil, **jamais une exception qui casse le run**. - **Menu + : section « Connecteurs »** — catalogue avec badge de statut (✓/✗/⚠/?), fiche par connecteur (Tester, Activer/Désactiver, Supprimer — les 2 derniers masqués pour les natifs) et formulaire « Ajouter un connecteur personnalisé » (nom, URL, clé en `type="password"`). ### Tests - `tests/test_v755_connectors.py` — **13 tests** : 3 natifs listés, clé jamais renvoyée + chiffrée en base (roundtrip Fernet), **5 URLs refusées** (localhost, 127.0.0.1, metadata cloud, ftp:, file:), toggle persisté + 404, 401 sans session, probe OK (clé bien envoyée) / probe erreur persistée, outil `connector_fetch` (schéma, succès, désactivé, inconnu), câblage du menu. - `test_v751`/`test_v753`/`test_v754` adaptés : **1 seule section « bientôt »** (plugins). - **Suite complète : 1306 passed / 0 failed** (`-n auto`), `ruff` 0, `eslint` 0 problème. ## v7.54.0 (2026-10-06) — Menu + : Mémoire de l'agent (phase 4/8) ### Added - **Mémoire de l'agent** — le moteur n'envoie jamais l'historique des messages (`AgentEngine.run()` ne construit que système + objectif courant) : chaque run repartait de zéro. Désormais une **ligne résumé par conversation** (`app/services/agent_memory.py`, table `agent_memory`, migration 31) est écrite à la fin de chaque run et **ré-injectée au contexte du run suivant** quand le toggle est activé. - `context_for()` : bloc « ## Mémoire de la conversation », **budget 4 000 caractères** (troncature par la gauche avec marqueur) ; - `remember()` : 1 note par échange (objectif 180 car. → réponse 700 car.), **20 notes max**, upsert, et **jamais une erreur ne fait échouer un run** ; - OFF = ni lecture ni écriture (contexte strict du run courant). - **Toggle « Mémoire » dans le menu +** — entrée racine à l'état réel (🧠 activée / 💭 désactivée avec le libellé correspondant), bascule via `PATCH /api/agent/conversations/{id}` (`memory_enabled` ajouté au whitelist), état persisté et reflété à l'ouverture/à la création de la conversation. - **`agent_conversations.memory_enabled`** (migration **31**, défaut `1`) + config **`AGENT_MEMORY_DEFAULT`** (`settings.agent_memory_default`) appliquée à la création d'une conversation ; la réponse de création expose la valeur. ### Tests - `tests/test_v754_memory.py` — **8 tests** : colonne + table créées par la migration, défaut ON à la création, toggle persisté relu en base, injection ON / absence OFF / persistance à travers un cycle OFF→ON, budget et nombre de notes bornés, **2 runs réels du moteur** (FakeLLM qui capture le prompt : mémoire présente ON, absente OFF), câblage front/back. - `test_v751` + `test_v753` adaptés : **2 sections « bientôt »** restantes (connecteurs, plugins). - **Suite complète : 1293 passed / 0 failed** (`-n auto`), `ruff` 0, `eslint` 0 problème. ## v7.53.0 (2026-10-06) — Menu + : Design System – Canevas (phase 3/8) ### Added - **Section « Design System – Canevas »** — liste des canevas (`GET /board/api/page-templates` : intégrés + personnels), puis une page de détail avec 3 actions : - **Créer une page à partir du canevas** (`POST …/0/use` avec `{key}` *dans le body* pour un intégré, `POST …/{id}/use` pour un personnel) ; - **Insérer dans le document ouvert** — `GET …/blocks` (nouvelle route) → `ensureBlockIds()` (global côté éditeur) → concaténation dans `E.blocks` + `autoSave/render`, désactivé si aucun document éditable n'est ouvert ; - **Enregistrer le document ouvert comme canevas** (`POST /board/api/page-templates` avec `page_id`). - **`GET /board/api/page-templates/{template_id}/blocks`** — blocs d'un canevas ( intégré via `template_id=0&key=`, sinon id) ; 404 clé/id inconnu, 500 si le JSON est corrompu. **OpenAPI : 516 chemins**, `info.version` 7.53.0. - **Canevas « Design System »** intégré (`design_system` dans `app/services/block_templates.py`) : callout tokens, TOC, 3 toggles composants, grille & espacement, checklist revue UI, citation. - **Navigation** : en-tête ← gère le niveau canevas → liste. ### Fixed - **Prefixe des canevas** : le front du panneau utilisait `/api/page-templates` (404 silencieux) — la route réelle est `/board/api/page-templates` (router `prefix="/board"`), comme `base.html` le fait déjà. ### Tests - `tests/test_v753_canvases.py` — **5 tests** : canevas `design_system` listé, route blocks intégré (sans `id`, 404 clé inconnue, `empty` par défaut), route blocks personnel + 404, création de page depuis le canevas (blocs lus en base, ids assignés), câblage des sections (3 « bientôt » restantes) + garde-fou « `key` dans le body ». `test_v751` adapté (3 sections désactivées). - **Suite complète : 1285 passed / 0 failed** (`-n auto`), `ruff` 0, `eslint` 0 problème. ## v7.52.0 (2026-10-06) — Menu + : Compétences-skills « Gérer » + « Parcourir » (phase 2/8) ### Added - **Sous-menu « Compétences-skills »** — 11 skills builtin + les skills enregistrés, avec **libellés affichés** « Deep research » (`/research`) et « Skill-creator » (`/create-new-skill`) via `FD_SKILL_ALIAS` (slugs intacts) ; le clic épingle le skill au contexte, même chemin que la palette `/`. - **« Gérer les compétences »** — section du menu : liste des skills, formulaire intégré (nom, description, prompt) pour créer/éditer, suppression avec nettoyage du chip épinglé, **export JSON** téléchargé et **import** en fichier (`overwrite:true` → jamais de doublon). - **« Parcourir les compétences »** — section galerie filtrable (nom / description / slug), installation en 1 clic (`installGallerySkill`). - **`PATCH /api/agent/skills/{skill_id}`** — édition partielle, whitelist de colonnes figée côté serveur, réponses 400 (champ vide / aucun champ), 401 (sans session), 404, 409 (nom déjà pris). **OpenAPI régénéré** : `docs/openapi-v2.json` → 515 chemins, `info.version` 7.52.0. - **En-tête ← + titre de section** dans le menu (indispensable à 3 niveaux : racine → compétences → gérer/parcourir/formulaire). - **Fix** : épingler un skill enregistré **n'efface plus** le message déjà tapé (`_pinDbSkill` ne vide le composer que s'il est vide). ### Tests - `tests/test_v752_skills_menu.py` — **7 tests** : PATCH complet + partiel, validations 400/404, 401 sans session (CSRF valide pour isoler la route), import sans doublon (409 puis `overwrite` → 1 seule ligne), aller-retour export → import, câblage des sections + alias, markup rendu dans la page. - `tests/test_v751_plus_menu.py` adapté : 4 sections « bientôt » au lieu de 5. - **Suite complète : 1280 passed / 0 failed** (`-n auto`), `ruff check app tests` propre, `eslint static/js/agent_panel_2.js` **0 problème**. ## v7.51.0 (2026-10-06) — Menu + de l'assistant : hub de contexte (phase 1/8) ### Added - **Menu à sections sur le bouton +** (`agent_panel.html` + `agent_panel_2.js`) : le + ouvre *Ajouter des fichiers ou répertoires*, *Compétences-skills*, *Connecteurs*, *Design System – Canevas*, *Add plugins*, *Mémoire (on/off)*. Sections pas encore livrées affichées « bientôt (phase N) » et **désactivées** (décision D3 : le menu ne promet rien que le code ne fait). Navigation clavier ↑/↓/Entrée/Échap, focus visible, Échap revient d'un niveau. - **Parcours fichiers & répertoires** — « Parcourir… » affiche un niveau à la fois via `/api/nav/menu` (contrat : dossier = `icon === 'folder'`) avec fil d'Ariane cliquable ; un document s'épingle (`document:`), un dossier s'épingle depuis la ligne « 📌 Épingler le dossier » (`folder:`). - **Jeton `folder:`** résolu par `ContextBuilder._mentions_context` : titre du dossier + documents directs, budget ~12k caractères (marqueur « contenu du dossier tronqué »). Enfants directs seulement — récursion à ajouter si les arbres profonds deviennent réels. - **« Rechercher… »** conserve l'ancien sélecteur de mentions (recherche par nom, `@` inline) à l'identique : zéro régression du chemin existant. ### Tests - `tests/test_v751_plus_menu.py` — **7 tests** : résolution du dossier, budget borné, id inconnu, contrat `/api/nav/menu`, rendu du menu dans la page, 6 sections dont 5 désactivées, jeton `folder:` côté front et côté backend. - **Suite complète : 1273 passed / 0 failed** (`-n auto`), `ruff check app tests` propre, `eslint static/js` **0 erreur**. ## v7.50.2 (2026-10-06) — Peek de Library : nu comme local-workspace (plus de bande ni double scrollbar) ### Fixed - **Le document ouvert par le bouton « Open » de Library n'occupait pas le panneau** — `.peek-body` gardait un `padding:16px 20px` + `overflow-y:auto` (héritage de l'ancien peek à rendu HTML) : l'iframe était amochée (519×816 mesurés contre 559×848 en local-workspace) avec une bande grise autour du document et une double scrollbar. Corps nu (`padding:0`, `overflow:hidden`) : l'éditeur remplit le panneau, exactement comme le peek « parfait » de /local-workspace (mesuré Playwright, cookie de session forgé). ### Tests - `test_library_peek_body_has_no_inset_padding`. Suite 1266 passed / 0 failed (-n auto) + ruff 0. ## v7.50.1 (2026-10-06) — Peek des bases : le contenu se rend enfin + tableau pleine largeur ### Fixed - **Une base ouverte en side peek restait à « Loading database… »** — `page_editor_embed.html` (le peek de `/pages/{id}?embed=1`) n'incluait pas `database_table.js` : `window.FlowDeckDB` était undefined dans l'iframe et le bloc `embed/collection` ne s'hydrat jamais. Inclus désormais (même traitement que `page_editor.html`). - **L'icône « Open » de fin de ligne d'un tableau de base ne faisait rien hors page dédiée** — le panneau `#db-side-peek` n'existait que dans le markup de `_database_table.html` ; sans lui, `openPageInSidePeek` était un no-op. Le panneau est créé à la demande par `database_table.js` (`ensureDbPeek`, comme `mt-peek` de My Tasks) : markup unique, le partial ne contient plus le panneau statique. - **Barre de scroll sous le tableau des bases** — `.database-table-container` plafonnait à 1100 px (règle héritée de Library) : le tableau ne prenait pas la largeur du document. Plafond retiré ; `max-width: 100%` aussi pour les conteneurs `.db-first` (base embarquée en premier bloc) et le titre des pages de base — les colonnes dictent seules le scroll horizontal. ### Tests - `test_embed_editor_can_render_inline_databases` (include peek + panneau créé à la demande, source unique de markup). Suite 1265 passed / 0 failed (-n auto) + ruff 0. ## v7.50.0 (2026-10-06) — Side peek : le document seul (mode édition), sans sidebar ni barre ### Fixed - **Tous les boutons « Open » ouvraient l'habillage pleine page dans le panneau latéral** — `library.js` (OPEN de hover), `local_workspace.js` (↢ Open + « Open in side peek » du menu contextuel) et `my_tasks.js` (peek des tâches) chargeaient `/pages/{id}` SANS `?embed=1` : le peek affichait sidebar + barre de navigation + éditeur (régression v7.49.0). Tous repassent en `/pages/{id}?embed=1` = le document en mode édition SEUL ; le cadre du panneau (garde/close/resize partagés `fdWirePeekResize`) reste identique. - **Le bouton « Open » du calendrier My Tasks ne faisait rien** — cause racine : le handler de clic testait `e.target.dataset.openRow`, mais les boutons `.mt-cal-open` du calendrier (et du Kanban) ne contiennent que leur `` : cliquer l'icône donnait `e.target` = `` sans dataset. Le handler résout désormais `t.closest('[data-open-row]')` — un seul chemin pour tableau, calendrier (5 vues) et Kanban. - **Menu contextuel global « Open in side peek »** — le clic ne faisait qu'afficher « Side peek not available in browser » : il route maintenant vers le peek de la page courante (`_libData.openPeekById` en Library, `_wsData.openSidePeek` en workspace local), repli nouvel onglet ailleurs. - **Peek d'une page de base (`content_format='collection'`)** — `?embed=1` forçait le template éditeur : une base ouverte dans le peek montrait un éditeur de blocs vide au lieu du tableau. Le template collection est gardé en mode embed (body.embed-mode retire sidebar + barre, le tableau reste). ### Added - **Bouton « Open » sur les tuiles du Kanban My Tasks** — le même que les lignes du tableau (`openButton(t)`, `.mt-hover-only` révélé au survol de la carte, overlay absolu pour ne pas déformer la tuile) ; ouvre le contenu de la tâche dans le panneau latéral. - **Bouton « pleine page » des peeks de bases** — navigue vers `/pages/{pageId}` (pleine page, sidebar + barre conservées) au lieu de l'URL `?embed=1` de l'iframe ; `dataset.page` porte l'id résolu. ### Tests - `test_my_tasks_workspace_coherence.py` : les 4 peek JS (`library`, `my_tasks`, `database_table`, `local_workspace`) doivent porter `?embed=1` (gate de cohérence inversé — l'ancien test imposait l'URL nue) ; nouveaux : résolution `closest('[data-open-row]')`, bouton Open sur tuiles Kanban, collection embed = tableau. Suite 1264 passed / 0 failed (-n auto) + ruff 0. ## v7.49.2 (2026-10-05) — Pages de base : titre au-dessus, tableau à gauche ### Fixed - **Le tableau d'une page de base commençait au 1/3 de la page** — une base créée depuis un document est un bloc `embed collection` rendu dans la colonne éditeur `.blocks-container` (`max-width: 900px; margin: 0 auto`) : centrée sur un grand écran, donc décalée loin du sidebar (mesuré x=694 sur 1920 px). Nouvelle classe `.db-first` sur le wrapper éditeur quand le premier bloc est une base : titre et blocs passent à `margin: 0` + `padding-left: var(--space-lg)` (liseré de la topbar) — tableau au ras du sidebar (x=256 mesuré), titre au-dessus, plus de débordement de l'embed (`max-width: 100%`, le scroll horizontal reste géré par `.db-table-wrapper`). - **Les pages de base dédiées (`content_format='collection'`) n'avaient pas de titre** — `page_editor_collection.html` rend le tableau sans en-tête de page. Un `

` est ajouté au-dessus du conteneur, avec le même liseré gauche que le tableau. ## v7.49.1 (2026-10-05) — Tableau des bases aligné sur le titre ### Fixed - **Le tableau d'une page de base était décalé sous le titre** — le conteneur `.database-table-container` avait `padding: 0 24px` tandis que la topbar (fil d'Ariane/titre) est à `var(--space-lg)` = 16 px : deux marges gauches différentes. Le conteneur passe à la même valeur que la topbar, `margin: 0` conservé — le bord gauche du tableau s'aligne désormais au ras du titre et de la marge. ## v7.49.0 (2026-10-05) — Side peek : l'éditeur plein écran, et un calendrier complet ### Added - **Calendrier My Tasks : cinq vues** — Mois, Année, Semaine, Jour et Planning, avec une navigation propre à chaque granularité (`←` / `→` et « Aujourd'hui » se recalculent : par exemple 5 – 11 octobre → 12 – 18 octobre en semaine, ou un jour en année). L'Année affiche les douze mini-mois de l'année courante et le Planning une ligne par jour avec ses tâches réparties par statut. - **Icône d'ouverture sur chaque tâche du calendrier** — même bouton « Open in side peek » que le tableau, révélé au survol. Elle porte le même attribut de ligne (`data-open-row`), donc un seul gestionnaire de clic pour toutes les vues : le titre est transmis par `data-open-title`, disponible là où il n'y a pas de ligne `.mt-row` (vues Jour, Semaine et Année). ### Fixed - **Impossible d'ajuster la largeur du side peek des bases** — le panneau avait été réécrit en bindings Alpine (`:class="dbPeekOpen"`, `@click.outside`) alors que le conteneur n'a aucun `x-data` : bindings inertes, `openPageInSidePeek` passait par `container.__x.$data` (undefined), le panneau ne s'ouvrait pas et la poignée de redimensionnement ne réagissait pas. Repassé en vanilla JS (câblage direct sur le document, repli discret pour les collections embarquées inline). Au passage : les quatre implémentations dupliquées du drag de largeur (Library, workspace local, My Tasks, bases) sont remplacées par un helper unique `window.fdWirePeekResize` (`app.js`) — pointer capture, borne 300 px–90 vw, clic = fermer, largeur persistée (`fd_peek_width`) et commune à tous les peeks, restaurée au chargement. My Tasks ne capturait ni le pointeur ni la largeur : son drag était erratique et non mémorisé. - **Largeur des pages standardisée sur celle de Library (1100 px)** — le conteneur des bases (`.database-table-container`) passe à `margin: 0` : le tableau est collé à gauche, au ras de la marge, au lieu d'être centré dans la page. `/trash` utilisait la classe `.lib-container` dont la règle vivait dans le `