diff --git a/CHANGELOG.md b/CHANGELOG.md
index 71caf1b..88fbdf0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,34 @@
# Changelog - FlowDeck
+## v7.42.0 (2026-10-01) — 🐛 BUG TOPBAR CORRIGÉ (boutons du header en texte brut)
+
+### Fixed
+
+- **Les `right_actions` du topbar étaient servis ÉCHAPPÉS sur TOUTES les
+ pages** (entities `"`/`<` → boutons Share/Star/Settings/Login en
+ texte brut). **Cause racine** : `{% set right_actions = '…' ~ fd_icon(…) ~ '…' %}`
+ — `fd_icon` est une **macro → `Markup`**, et `Markup.__radd__/__add__`
+ **échappe ses arguments `str`** → tous les segments littéraux sortent
+ entité-és ; le `|safe` de `_header:141` est then no-op sur un Markup déjà
+ échappé. Découvert en cherchant l'échec du gate éditeur CSP (v7.41.0),
+ reproductible partout (`curl /workspaces`).
+- **Fix racine (5 templates)** : conversion en **block-set**
+ `{% set right_actions %}…{{ fd_icon(…) }}…{% endset %}` (source = brute,
+ interpolation = Markup brut — la forme idiomatique Jinja) :
+ `gitea_workspace`, `page_editor`, `page_editor_collection`, `workspace`,
+ `workspaces`. Piège du script : regex greedy multi-lignes = set avalé →
+ matcher **une ligne**.
+- **Test permanent** `tests/test_topbar_right_actions.py` : `/workspaces`
+ doit contenir `class="topbar-btn"` parsé et ZÉRIE entité `"`.
+- Gate éditeur : l'assertion `.star-btn` **ré-ajoutée** (les boutons
+ rendent à nouveau).
+
+### Notes
+
+- Probable régression depuis A10 (activation d'autoescape) : les `~`
+ étaient des no-op avant, Markup.__radd__ échappait déjà… les
+ `|safe` devenaient nécessaires et ne pouvaient plus réparer.
+
## v7.41.0 (2026-10-01) — A20 phase 3 LOT 3b : gitea + agent + éditeur verts
### Changed
diff --git a/ROADMAP.md b/ROADMAP.md
index 4d70f77..df851c1 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1143,7 +1143,7 @@ Quality DB views, Agent IA Palette → Realtime + E
**LOT 3a fait 2026-10-01 (v7.40.0)** : gate « surfaces simples » = **welcome, trash, accounts, workspace, import VERTS** (4 sans aucune modification — scan propre + registres lot 1) + **fix bug pré-existant `/import`** (x-text `report.relations` non garde → pageerror sous les 2 builds). **8 surfaces couvertes** au total.
**LOT 3b fait 2026-10-01 (v7.41.0)** : **gitea_workspace + agent_panel + page_editor VERTS** (éditeur = 15 sites : 12 `window.E` → délégués `edCall`/`edTimeAgo`/`edCommentCount`/`edShared`/`bindStar` dans appState + 3 dans `_page_editor_content` → `openBacklink`/`fmtImportSize`/`bindIconHtml` ; piège Jinja : quotes `\'` dans le `set`). **12 surfaces vertes** au total (7 gates).
**Reste ph3** : board/table_view/teamload/card_detail (scan propre, gates liés au contexte Gitea — gitea actuellement down) → csp_preview vert partout, puis bascule réelle (retrait unsafe-eval).
-**🐛 BUG pré-existant à analyser (trouvé v7.41.0)** : **`right_actions` du topbar servi ÉCHAPPÉ sur TOUTES les pages** (entities `"`/`<`, boutons Share/Star/Settings en texte brut — repro `curl /workspaces`); `_header:141` a bien `|safe`, l'ENV est standard, rendu local du même motif = PARSED → cause serveur à cerner (piste : valeur échappée à la construction du set). Effort : **M**. `img-src` : si un proxy d'images local arrive. Effort : **L** (reste : L, plan ci-dessus).
+**🐛 BUG TOPBAR — CORRIGÉ 2026-10-01 (v7.42.0)** : cause racine = `'literal' ~ fd_icon(…)` — `fd_icon` = macro → `Markup`, et `Markup.__radd__/__add__` **échappe les segments `str` littéraux** → boutons en entities partout ; `|safe` no-op sur Markup déjà échappé. Fix : **block-set** `{% set right_actions %}…{{ fd_icon() }}…{% endset %}` dans les 5 templates (gitea, page_editor ×2, workspace, workspaces) + **test permanent** `tests/test_topbar_right_actions.py` + assertion `.star-btn` rétablie dans le gate éditeur. Probable régression depuis A10 (autoescape). `img-src` : si un proxy d'images local arrive. Effort : **L** (reste : L, plan ci-dessus).
- [x] **A21 — `sqlite3` synchrone sur l'event loop — PARTIEL 2026-09-30 → phase 1 le 2026-10-01 : `PRAGMA busy_timeout=5000` ajouté au point d'entrée unique `get_conn()` (db.py)** ; reste le wrapper async `anyio.to_thread` + la migration des 510 call sites : `get_conn()` (`db.py:833-843`) est synchrone et **510 des 689 `async def` de routes** l'appellent (805 occurrences au total ; 0 `run_in_threadpool`, 1 seul `asyncio.to_thread` dans tout le dépôt : `semantic_search.py:262`) ; connexion neuve par requête (`connect` + 2 PRAGMA), **aucun `busy_timeout`**. Chaque requête bloque la boucle. *Fix : wrapper async (`anyio.to_thread.run_sync`) partagé, migrer d'abord `api_v2`/`dashboard`/`collections`/`board` + `PRAGMA busy_timeout=5000`.* — **phase 1 faite 2026-10-01** : **352 routes `async def` SANS aucun `await`** converties en `def` (scan corps par corps : ni `await`/`async with`/`async for`, ni `asyncio`) → FastAPI les exécute dans son threadpool, donc tout leur travail SQLite quitte l'event loop, sans changer une ligne de logique (api_v2 : 60, dashboard : 40, collections : 25, board : 23, + main.py : 6 ; aucune occurrence `asyncio`/`run_coroutine` dans les corps convertis). **Phase 2a faite 2026-10-01 (api_v2)** : les routes dont le SEUL await était `body = await request.json()` (36) → paramètre FastAPI `body: dict = Body(default={})` (parsing fait par FastAPI avant l'appel, équivalences vérifiées : corps absent → `{}` comme le try/except d'avant, JSON invalide → 422 au lieu d'un silencieux `{}`) + conversion en `def` → **api_v2 passe à 96/115 routes hors loop**. **Phase 2b faite 2026-10-01 (api_v2 bouclé)** : helper `run_event_sync(coro)` (automations.py) — `asyncio.run` sur une boucle dédiée dans le worker threadpool : l'événement est EXÉCUTÉ ET ATTENDU avant la réponse (déterministe comme l'await) mais ne bloque jamais la boucle ; les 15 routes dont les seuls awaits étaient `json`/`_fire_event`/`fire_published`/`fire_unpublished` passent en `def`. **api_v2 : 111/115 routes hors loop**, les 4 restantes ont de vrais awaits réseau (`import_csv_v2` multipart, `project_tree_v2` (gitea), `test_webhook_v2`, `retry_webhook_deliveries`). **Phase 2c faite 2026-10-01 (repo-wide)** : **283 → 93 routes async** (**86 % des 667 routes hors loop**, avant 61 %) en 4 passes — (A) racine auth : `get_current_user` (session.py) + ses clones async (`agent._current_user_id/_workspace_id/_current_admin`, `sso._require_admin`) étaient `async` **sans aucun await** → `def`, **47 `await` supprimés** dont 3 via l'alias `gcu` (piège : grep littéral aveugle, rattrapé par la suite) ; (B) re-scan → 19 routes flipées ; (C/D) **155 routes** `request.json`/événements → `Body(default={})` (3 formes : try/except `body = {}` intact, try/except `raise HTTPException(400)` → `Body(...)` requis (422 FastAPI, **0 test sur le 400**), forme conditionnelle content-type ×54 → défaut `{}`) + `run_event_sync` → `def`. **Reste async (93, justifié)** : form/upload/file (22), gitea/llm/oidc réseau (~25), `_json_body` 9, 2 JSON inline en argument, 1 fallback logique, 1 lecture conditionnelle web_clipper, + mixtes json+réseau. **A21 : fait** (sauf l'idée initiale d'`anyio.to_thread` par bloc DB — **inutile** : les routes sont DÉJÀ hors loop, le SQLite synchrone n'est plus sur la boucle). Effort : **L** (fait).
- [x] **A22 — Validateur d'upload = code mort** : `validate_upload()` + `ALLOWED_EXTENSIONS` + `MAX_UPLOAD_SIZE` (`security.py:33-40`) n'ont **aucun appelant** ; `upload_local_workspace_file` (`dashboard.py:1507-1574`) fait `file_path.write_bytes(content)` après seulement `Path(filename).name` — ni taille, ni extension, ni auth, et `/api/local-workspace` est exclu du CSRF. Chemin aussi codé en dur `Path(f"/data/uploads/...")` au lieu de `FLOWDECK_DATA_DIR`. *Fix : appeler `validate_upload()` avant `read()` + session. Effort : **S**.*
- [x] **A23 — N+1 avérés** : `dashboard.py:905` (`COUNT(*) FROM pages` par page dans une boucle de 20), `board.py:2141-2146` (INSERT + `_extract_ai_keywords` par issue), `collections.py:378` (INSERT par propriété en boucle). *Fix : une agrégation `GROUP BY` / un `executemany`. Effort : **S**.*
diff --git a/VERSION b/VERSION
index 8d82fb5..fa52423 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-7.41.0
+7.42.0
diff --git a/WORKLOAD.md b/WORKLOAD.md
index 7c5110e..495cd4b 100644
--- a/WORKLOAD.md
+++ b/WORKLOAD.md
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone
-> **Début**: 2026-07-08 | **Version**: v7.41.0 (A20 ph3 LOT 3b : gitea + agent + éditeur verts — 12 sites window.E délégués; BUG pré-existant : right_actions servi échappé partout) | **Statut**: EN COURS 🔄
+> **Début**: 2026-07-08 | **Version**: v7.42.0 (BUG TOPBAR CORRIGÉ : « 'literal' ~ fd_icon » échappait les boutons → block-set ×5 + test permanent) | **Statut**: EN COURS 🔄
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
## Avancement Global
diff --git a/app/main.py b/app/main.py
index 5862206..4456b6c 100644
--- a/app/main.py
+++ b/app/main.py
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
- version="7.41.0",
+ version="7.42.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
diff --git a/app/templates/gitea_workspace.html b/app/templates/gitea_workspace.html
index fe6f006..5303690 100644
--- a/app/templates/gitea_workspace.html
+++ b/app/templates/gitea_workspace.html
@@ -6,7 +6,7 @@
{% block topbar %}
{% set breadcrumb_items = [{"label": owner ~ "/" ~ repo, "url": None}] %}
{% set page_icon = "link" %}
-{% set right_actions = '' ~ fd_icon("settings",16) ~ '' %}
+{% set right_actions %}{{ fd_icon("settings",16) }}{% endset %}
{% include '_header.html' %}
{% endblock %}
diff --git a/app/templates/page_editor.html b/app/templates/page_editor.html
index a9ae77f..a5ca267 100644
--- a/app/templates/page_editor.html
+++ b/app/templates/page_editor.html
@@ -3,7 +3,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
{% set page_icon = "file" if page.content_format != 'file' else "paperclip" %}
{% set page_title = page.title %}
{% set nav_page_id = page.id %}
-{% set right_actions = 'Edited ▾' %}
+{% set right_actions %}Edited ▾{% endset %}
{% include '_header.html' %}
{% endblock %} {% block content %}
{% include "_page_editor_content.html" %}
diff --git a/app/templates/page_editor_collection.html b/app/templates/page_editor_collection.html
index ab58a3e..072c6c0 100644
--- a/app/templates/page_editor_collection.html
+++ b/app/templates/page_editor_collection.html
@@ -2,7 +2,7 @@
page_title %}{{ page.title }}{% endblock %} {% block topbar %}
{% set page_icon = "file" %}
{% set page_title = page.title %}
-{% set right_actions = 'Edited ▾' %}
+{% set right_actions %}Edited ▾{% endset %}
{% include '_header.html' %}
{% endblock %} {% block content %}
{% include "_database_table.html" %}
diff --git a/app/templates/workspace.html b/app/templates/workspace.html
index 8f2f398..7408e7f 100644
--- a/app/templates/workspace.html
+++ b/app/templates/workspace.html
@@ -6,7 +6,7 @@
{% block topbar %}
{% set page_icon = "home" %}
{% set page_title = "Workspace — Projects" %}
-{% set right_actions = '' ~ fd_icon("key",16) ~ '' ~ fd_icon("settings",16) ~ '' %}
+{% set right_actions %}{{ fd_icon("key",16) }}{{ fd_icon("settings",16) }}{% endset %}
{% include '_header.html' %}
{% endblock %}
diff --git a/app/templates/workspaces.html b/app/templates/workspaces.html
index e62819f..3163ecb 100644
--- a/app/templates/workspaces.html
+++ b/app/templates/workspaces.html
@@ -6,7 +6,7 @@
{% block topbar %}
{% set breadcrumb_items = [{"label": "Workspaces", "url": None}] %}
{% set page_icon = "home" %}
-{% set right_actions = '' ~ fd_icon("key",16) ~ '' ~ fd_icon("settings",16) ~ '' %}
+{% set right_actions %}{{ fd_icon("key",16) }}{{ fd_icon("settings",16) }}{% endset %}
{% include '_header.html' %}
{% endblock %}
diff --git a/docs/openapi-v2.json b/docs/openapi-v2.json
index 0b6107e..e90823b 100644
--- a/docs/openapi-v2.json
+++ b/docs/openapi-v2.json
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
- "version": "7.41.0"
+ "version": "7.42.0"
},
"paths": {
"/auth/register": {
diff --git a/e2e/csp_preview.spec.js b/e2e/csp_preview.spec.js
index 3f45b0e..f87669d 100644
--- a/e2e/csp_preview.spec.js
+++ b/e2e/csp_preview.spec.js
@@ -134,10 +134,9 @@ test('A20-ph3 : éditeur de page (right_actions) sous build CSP', async ({ page
});
expect(editor.delegates).toBe('ok');
expect(editor.ed).toBe('ok');
- // (le rendu des boutons right_actions est cassé côté SERVEUR —
- // entities " sur TOUTES les pages, régression pré-existante
- // documentée au ROADMAP — donc on n'asserte pas leur présence ;
- // le filet 0-erreur = les expressions évaluées sont parsables.)
+ // les boutons du topbar sont réellement servis (bug A10 « ~ + Markup »
+ // corrigé : block-set) ET leurs expressions passent le filet CSP
+ await expect(page.locator('.star-btn').first()).toBeAttached();
} finally {
await page.evaluate(async (id) => {
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
diff --git a/tests/test_topbar_right_actions.py b/tests/test_topbar_right_actions.py
new file mode 100644
index 0000000..436a3b4
--- /dev/null
+++ b/tests/test_topbar_right_actions.py
@@ -0,0 +1,22 @@
+"""Les `right_actions` du topbar doivent être servis PARSED (A10).
+
+Regresssion historique : `'literal' ~ fd_icon(...)` — fd_icon = macro →
+Markup, et `Markup.__radd__/__add__` échappe les segments littéraux →
+boutons livrés en entities (`"`) sur TOUTES les pages. Fix : block-set
+`{% set x %}…{{ fd_icon() }}…{% endset %}` (5 templates).
+"""
+from tests.conftest import login_test_client
+
+
+def test_right_actions_parsed(client):
+ login_test_client(client)
+ r = client.get("/workspaces")
+ assert r.status_code == 200
+ body = r.text
+ parsed = 'class="topbar-btn"' in body
+ escaped = ""topbar-btn"" in body
+ print("PARSE:", parsed, "| ESCAPED:", escaped)
+ print("DBG present:", "DBGCLS" in body)
+ k = body.find("DBGCLS=")
+ print("CTX:", body[max(0, k - 80) : k + 320].replace("\n", " ") if k >= 0 else "pas de DBG")
+ assert parsed and not escaped, "right_actions servi échappé"