Scan strict dashboard.py : 44 routes à 0 référence stricte sur 63. 10
couvertes cette passe (test_smoke_uncovered.py → 32 tests) :
- Tags CRUD complet : POST (nom lowercasé `SmokeTag` → `smoketag`), GET all
(présent), PUT couleur (relue), DELETE (absente de la liste ensuite)
- Vie d'une page : GET /api/pages/{id}/content (contenu seedé relu) →
PUT rename (ok + **400 titre vide** + titre relu en base) →
POST trash (parent_section='Trash' + deleted_at RELUS en base) ;
nettoyage en finally
- GET /api/sidebar/workspace-tree : 200 HTML, fragment « No pages yet »
(pas de cookie workspace)
- POST /api/settings/avatar-color : couleur relue SUR L'UTILISATEUR DE LA
SESSION (pas LIMIT 1), avatar_color/avatar_url d'origine restaurés
- GET /api/workspace/1/members : shape {"members": [...]}
Helper _seed_page : les colonnes par défaut sont surchargeables (content=)
pour les seeds à contenu.
Reste A32 : dashboard 34 routes à 0 ref (fichiers/avatars/imports…) +
6 routes Gitea d'api.py (stub transport httpx).
suite **1069/1069** · `ruff check app tests` OK · docs à jour
Scan strict des 115 routes api_v2.py contre tous les tests (chaîne de chemin
littérale) → 5 routes sans AUCUNE référence, toutes couvertes maintenant :
- POST /properties/evaluate-formula : 200 + shape, 400 sans expression.
Le moteur renvoie « 1 + 2 » tel quel aujourd'hui → le smoke valide le câble
(bearer, Body param, parse), pas le moteur (réalm de ses propres tests).
- POST /properties/compute-rollup : 400 « collection_id required »,
401 sans bearer.
- GET /admin/audit-logs : portail admin VÉRIFIÉ — l'attendu est calculé
depuis /users/me (le tout premier utilisateur d'un worker est admin :
état non contrôlable depuis un test isolé), + token scope admin → 200 + logs.
- GET /webhooks/events : catalogue non vide + wildcards * / page.*.
- POST /webhooks/verify-signature : valid=True avec sign_payload() (le même
helper que le serveur), False avec signature bidon.
test_smoke_uncovered.py : 27 tests. Reste A32 : dashboard 17/63 + 6 routes
gitea d'api.py (stub transport).
suite **1064/1064** · `ruff check app tests` OK · docs à jour
+6 smokes dans test_smoke_uncovered.py (22 tests au total dans le fichier) :
- board-config GET/POST : défauts 5 colonnes sans board, création puis
relecture du roundtrip (seed via l'endpoint lui-même, pas de SQL brut)
- col-mapping POST/DELETE : 404 sans board, upsert label vérifié, suppression
- card POST : 404 sans board, ok avec
- collaborators GET : gitea.get_collaborators STUBBÉ (zéro accès réseau réel)
- frontend-error(s) : capture, JSON invalide → ignored, DÉDUP d'une erreur
répétée (count=2), lecture qui purge (cleared=true puis 0)
- checklist mutations : PATCH item (checked/content relus EN BASE), DELETE
item, DELETE checklist (COUNT=0) — seed + cleanup en finally
Reste api.py : 6 routes Gitea (issues ×4 + créations checklists owner/repo) →
stub de transport httpx (phase suivante). Reste global : dashboard 17/63,
api_v2 50/115.
suite **1059/1059** (236 s) · `ruff check app tests` OK · docs à jour
- tests/test_smoke_uncovered.py : +6 tests pour library.py (1/10 → 8 routes
couvertes) : les 5 listes en boucle (recents/favorites/published/private/
workspace → 200 + items), /private avec page seedée retrouvée, /children/{id}
avec parent/enfant seedés, /repository vide ET clé (string, aucun réseau),
non-régression 404 sur les routes supprimées.
- DÉCOUVERTE (les smokes l'ont prouvé) : `/api/library/local-workspace-children`
renvoyait un 500 systématique (test vert → ASGI double response.start) et
`/api/library/local-workspace` 500 dès qu'un workspace existe — les deux
lisaient `local_workspace_items`, table AUCUNEMENT créée dans le codebase
(0 CREATE TABLE) et sans 1 seule référence front. Supprimés avec
`library._format_size` devenu mort (la copie de dashboard.py est inchangée).
`local_workspace_items` : 0 occurrence restante dans app/.
- helper `_seed_page` minimal (workspace NOT NULL inclus) + nettoyage en
finally (pas de pollution des autres tests).
suite **1053/1053** (229 s) · `ruff check app tests` OK · docs à jour
restent phase 2b : api.py 3/23, dashboard.py 17/63, api_v2.py 50/115
tests/test_smoke_uncovered.py — un smoke par route des 4 routers qui n'avaient
AUCUN test :
- webhooks.py 3/3 : réception sans secret → {"status":"ok"} ; HMAC faux → 401
(secret piloté par monkeypatch, déterministe quel que soit le .env) ;
register sans secret → 400 AVANT tout appel réseau ; status avec
gitea.list_webhooks stubbé → {"registered": False} (zéro réseau réel)
- notes.py 2/2 : GET HTML + roundtrip POST→GET (upsert persisté en base,
échappement HTML vérifié : <b> et non <b>)
- sidebar_config.py 2/2 : GET défauts ; PUT persisté puis RELU depuis
users.sidebar_config ; 400 sans config ; remise en état en fin de test
- github_routes.py 2/2 : status {"linked": False} ; disconnect {"status": "ok"}
Reste (A32 phase 2) : quasi nuls — library 1/10, api 3/23, dashboard 17/63,
api_v2 50/115 → même recette, fixture client existante.
suite **1047/1047** · `ruff check app tests` OK · docs à jour
- `run_event_sync(coro)` (app/services/automations.py) : exécute une coroutine
d'événement depuis un handler synchrone — `asyncio.run` sur une boucle
dédiée dans le worker threadpool : le worker est bloqué, JAMAIS la boucle
d'event, et la réponse n'est produite qu'une fois l'événement terminé
(déterministe, équivalent sémantique de l'await). ponytail: les clients
httpx sont créés à chaque appel partout → aucun lien de boucle ; sinon
run_coroutine_threadsafe + boucle du lifespan.
- 15 routes api_v2 dont les SEULS awaits étaient `request.json`,
`_fire_event`, `fire_published`, `fire_unpublished` →
`Body(default={})` + `run_event_sync(...)` + conversion en `def` (script
: wrapping par appariement de parenthèses chaîne-aware, assert de flip
« plus aucun await »).
- api_v2 : **111/115 routes hors loop**. Les 4 restantes ont de vrais awaits
réseau et restent async volontairement : import_csv_v2 (multipart),
project_tree_v2 (gitea), test_webhook_v2 (delivery), retry_webhook_deliveries.
- Repo-wide : 403 routes sync (hors loop) / 260 async (phase 2c).
tests : ciblé public_api_v2 + v65 + webhooks_v2 + audit = 90/90 (les webhooks
prouvent la détermination de run_event_sync) ; suite complète **1037/1037**
(228 s) · `ruff check app tests` OK · docs à jour
- Conversion `async def` → `def` de TOUTES les routes dont le corps ne contient
ni `await`, ni `async with`, ni `async for`, ni `asyncio` (scan automatique
corps par corps sur app/ : 352 converties, 0 dangereuses, vérifié
`asyncio`/`run_coroutine`/`.result()` absents). FastAPI exécute ces handlers
dans son threadpool → tout leur SQLite (`get_conn()` + `conn.execute`) quitte
l'event loop, sans changer une ligne de logique.
- Répartition : api_v2 60, dashboard 40, collections 25, board 23,
workspace 19, wiki 17, permissions 14, api 14, main.py 6, + 35 fichiers.
- Les 4 routers prioritaires de l'audit sont couverts par ce lot :
api_v2 60 + dashboard 40 + collections 25 + board 23 = 148 conversions
(le reste de leurs routes attend la phase 2 : elles ont de vrais `await`).
- Reste (phase 2) : les 311 routes avec de vrais `await` → enrouler les blocs
DB dans `await anyio.to_thread.run_sync(...)` ; pas de wrapper partagé livré
(rien ne l'appellerait — YAGNI jusqu'au premier usage).
suite **1037/1037** (233 s) · `ruff check app tests` OK · docs à jour
- ContentSecurityPolicyMiddleware : nonce aléatoire par requête dans la
ContextVar `CSP_NONCE` (posée avant `call_next` → visible des templates),
`script-src 'self' 'unsafe-eval' 'nonce-…'` — plus aucun script inline
sans nonce ne tourne (fin des XSS injectés en JS)
- 38 tags `<script>` des templates : `nonce="{{ csp_nonce() }}"` (passage
scripté, vérifié : 0 restant) ; `LOCAL_LOGIN_HTML` (constante de module) :
helper `_with_nonce()` au rendu ; collections.py : 3 scripts Python
(chart/form/map) noncés
- `<meta name="htmx-config" content='{"inlineScriptNonce": …}'>` dans base.html
: htmx ré-injecte les <script> des réponses boostées avec le bon nonce
- `script-src-attr 'unsafe-inline'` : les 74 handlers `onclick=` inline
restent couverts (le nonce les aurait désactivés aussi)
- chart.js (cdn.jsdelivr.net) et leaflet (unpkg) ajoutés à script-src/style-src
: vues chart/map déjà BLOQUÉES par la CSP depuis toujours
(commentaire ponytail: upgrade = vendoriser puis retirer les hôtes)
- reste d'A20 : unsafe-eval (Alpine x-data → @alpinejs/csp), externalisation
JS (A27), resserrer img-src/connect-src
test : test_csp_nonce_per_request (page base.html + page hors template,
nonce unique par requête)
suite **1037/1037** · `ruff check app tests` OK · docs à jour
- `_apply_one()` : BEGIN explicite → `fn(conn)` → marque `schema_version` →
commit ; rollback complet à l'échec. Avant le DDL sortait en autocommit
(isolation_level legacy) : un échec au milieu laissait un schéma partiel
commité SANS ligne de version, et la reprise rejouait un DDL déjà appliqué.
Si une transaction englobante subsiste (init_db commit juste avant), on la
vide d'abord plutôt que de l'englober.
- Helper unique `columns(conn, table)` (valide l'identifiant, ValueError sinon)
: 25 copies de `{r[1] for r in conn.execute("PRAGMA table_info(...)")}`
éliminées dans migrations.py (21 littéraux + 3 f-string + 1 variante row).
`table_exists`/`column_exists` préconisés par l'audit NON livrés : aucune
migration n'interroge sqlite_master, un contrôle unitaire se lit dans le set.
- Smoke : DB fraîche → 28 migrations → version 29, ré-apply idempotent.
tests : test_migration_transaction_rolls_back (DDL partiel annulé + zéro marque
de version), test_columns_helper_validates_table_name
suite **1036/1036** · `ruff check app tests` OK · OpenAPI 511 chemins / 7.6.0
docs (ROADMAP/CHANGELOG/WORKLOAD/VERSION) à jour
- A26 — champ `standalone` mort supprimé (0 lecteur) ; `sqlite:////data/…` ne
produit plus de chemin UNC sous Windows ; `.env.example` ne promet plus
PostgreSQL ; raise au boot si `APP_SECRET_KEY` vaut la valeur par défaut
- A33 — rate limit : préfixes manquants (`/scim/v2/`, `/workspace/`, `/db/` +
non-GET sur `/s/` et `/f/`), limite lue dans `settings.rate_limit_requests`
(60 annoncés / 100 codés en dur), clé `X-Forwarded-For` seulement derrière un
proxy local (anti-spoof), `_store` épuré (mémoire bornée) + test dédié
- A34 — `_spawn()` : les 10 schedulers loggent leur exception et redémarrent
après 10 s au lieu de mourir en silence ; 2 `logger.debug` de scheduler → warning
- A35 — OpenAPI régénéré 439 → 511 chemins (`info.version 7.3.9`), README
(était v6.7.0), compteur API_GUIDE, titre dupliqué retiré du ROADMAP ;
le drift Python 3.12/3.13 reste noté (rebuild d'image à valider)
- A36 — 4 dépendances mortes purgées de requirements.txt (aiosqlite, slowapi,
loguru, packaging = 0 import) ; pyproject reste sans [project] : Docker et la
CI installent requirements.txt, dupliquer les 22 deps créerait 2 sources
- A43 — 15 `datetime.utcnow()` → `now(UTC).replace(tzinfo=None)` (format ISO
naïf identique, warnings de dépréciation divisés : 2374 → 926)
suite **1029/1029** · `ruff check app tests` OK · docs/ROADMAP/CHANGELOG/WORKLOAD à jour
- A25 — 84 `except Exception: pass/…` → `logger.exception("<fonction>")`
(19 fichiers : api_v2 30, dashboard 10, board 7, sites 5, workspace 5,
api_v2_helpers 5, …) ; `logger` ajouté là où il manquait (api_v2_helpers,
sites + `import logging`)
- A25 critique — les `try` autour de `materialize_properties` supprimés dans
`create_collection_v2` ET `apply_db_template_v2` : un échec interrompt la
transaction au lieu de commiter une collection sans schéma
- test `test_collection_rollback_when_materialize_fails` (Bearer v2, monkeypatch
qui lève, assertions : RuntimeError + 0 ligne commitée)
- A21 partiel — `PRAGMA busy_timeout=5000` dans `get_conn()` (point d'entrée
unique) ; commentaire `ponytail:` : le wrapper async + les 510 call sites
restent à migrer module par module
- suite **1028/1028** · `ruff check app tests` OK
- `_current_user_id` : 401 sans session (24 sites) au lieu de retomber sur
`SELECT id FROM users WHERE login='admin'`
- `_current_admin` : suppression du même fallback — `PATCH /api/agent/providers`
et `POST /api/agent/providers/test` (donc `LLMClient.ping(api_base=…)`)
exigent une session admin : 401 sans session, 403 non-admin
- `_check_api_base()` sur les 2 routes : scheme http(s), pas d'identifiants
dans l'URL (400) ; hôtes privés maintenus — Ollama `localhost:11434` est le
provider par défaut du produit (commentaire `ponytail:` pour la fermeture)
- +1 test de non-régression → suite **1027/1027**, `ruff check app tests` OK
- Scan de tous les appels `fetch` non-GET du front par préfixe : 12 préfixes
n'ont AUCUN call site sans `X-CSRF-Token` → sortis de `EXCLUDED_PATHS`
(`/db/`, `/workspace`, `/workspace/automations`, `/board/api/favorites`,
`/api/pages`, `/api/recents`, `/api/notifications`, `/api/comments`,
`/api/automations`, `/api/github`, `/api/admin`, `/api/onboarding`)
- `welcome.html` : les 2 POST `/api/onboarding/*` reçoivent le header
(`adminFetch` prouve que `/api/admin` était déjà couvert)
- reste A19 (front à équiper) : `/api/workspace` (+`/api/workspaces`),
`/api/local-workspace`, `/api/settings`, `/api/gitea`, `/api/agent` — 49 fetch
- tests : helper `anon_csrf()` (anonyme + CSRF valide) pour isoler les 401 de
route du 403 middleware — 4 tests d'anonymat ajustés
- suite **1026/1026** · `ruff check app tests` OK
- A11 : `GET /api/settings/avatar/{filename:path}` → `resolve()` + `relative_to()` (motif de `serve_uploaded_file`), 403 hors de `/data/avatars`
- A18 : `GET /workspace/public/{id}` → 404 HTML explicite pour `permission_type` restricted/private, `html.escape` sur le nom, l'icône et les titres de lignes (le f-string HTML ne passe pas par Jinja2)
- `tests/test_audit_p0_fixes.py` : 3 tests de non-régression (traversal, échappement, hidden restricted)
- ROADMAP A11/A18 cochés · CHANGELOG/WORKLOAD/VERSION → 7.3.2 · suite **1019/1019** · `ruff check app tests` OK
- `app/templating.py` : `ENV = Environment(loader=FileSystemLoader("app/templates"), autoescape=select_autoescape(["html"]))`
- les 29 instantiations `Environment(loader=FileSystemLoader("app/templates"))` (9 routers) remplacées par `env = ENV` — plus aucune interpolation `{{ … }}` servie crue, les `|safe` redeviennent efficaces
- re-tri des `|safe` : `card_detail.html` corps d'issue et commentaires échappés (XSS stocké), placeholder de description sorti du ternaire, `sidebar_config` passé en dict + `|tojson` (échappement `</script>` en contexte script)
- `|safe` conservé sur `right_actions` (HTML fabriqué dans les templates, fiable)
- `ruff check app tests` OK · suite **1016/1016 verts**
v5.11.0 Wiki-links & mentions de page :
- tokens [[fdpage:ID]] / [[fddate:ISO]] dans le texte des blocs,
service app/services/wiki_links.py (labels, rendu HTML, extraction)
- taper [[ ouvre le picker de pages (recherche floue, clavier) ;
le menu @ gagne les sections Pages et Date (today/tomorrow/YYYY-MM-DD)
- chips atomiques contenteditable=false relues en tokens par gtTok()
(autosave/drag/undo preservent les liens) ; renommage propage via
GET /board/api/wiki/titles ; backlinks reconnaissent les tokens ;
page publique rend les chips (echopee)
v5.12.0 Templates & verrouillage :
- template picker global sur + New page : 5 built-in
(app/services/block_templates.py) + templates perso
(table page_global_templates, migration 13)
- POST /board/api/page-templates (save current page) + /{id}/use
(instantiate, id 0 = built-in par cle)
- page lock : POST /api/pages/{id}/lock, garde _ensure_page_editable
-> 423 en ecriture pour les non-privileged, deblocage par
locked_by ou admin seulement (403 sinon), banniere + read-only UI
- full-width / small text par page (pages.full_width/font_small,
POST /api/pages/{id}/options, classes CSS)
- migration 13 : is_locked, locked_by, full_width, font_small,
page_global_templates
Tests : tests/test_v511_v512_wiki_templates.py (15) ; suite complete
538 verte ; ruff OK ; node --check des templates JS OK.
tests/conftest.py: mutate the settings singleton (instead of rebinding) so DB + backup dir are isolated per test -> pytest-xdist safe.
Real backup tests (snapshot/prune/admin API) and OAuth mock tests (Gitea/GitHub/link) replace the previous skips.
init_db() now also creates webhook_subscriptions (full schema without the FastAPI lifespan).
ruff check is clean; .eslintrc.json migrated to eslint.config.mjs (flat config).
CI: lint job (ruff + eslint), parallel tests (-n auto), run on every branch push.
VERSION 5.11.1.
- Correctif : la fenêtre Share rechargait ses partages à chaque ouverture (before: liste vide après refresh)
- Sidebar « Shared » scindée en sous-groupes « Par moi » (partages nominatifs + liens) et « Avec moi » (partages reçus), sans doublons
- Bibliothèque : filtre « Tous / Par moi / Avec moi » sur l'onglet Shared ; /api/library/shared?dir=made|received|all + share_dir par élément
- Document : bouton barre affiche « 👥 Shared ▾ » quand la page est partagée (membre, lien ou publiée), recalculé à la volée
- Workspace local : emoji 👥 juste avant le nom des fichiers partagés (is_shared exposé par /api/local-workspace/tree)
- Tests +6 (tests/test_sharing.py) : direction made/received/all, fallback dir invalide, rendu pageIsShared ; suite 325 verte (+3 PDF pré-existants)
- Fix "Invalid permission" : le client envoyait editor/commenter/viewer alors que
l'API attend view/comment/edit (invitePermission -> 'edit', menu participants aligné)
- PUT /api/pages/{page_id}/share/{share_id} : mise à jour de permission persistée
- Autocomplete des membres existants dans le champ d'invitation (search users + debounce,
navigation clavier, envoi user_id pour lier le partage au compte)
- Upsert anti-doublon dans le partage + trim email backend
- 12 tests tests/test_sharing.py ; suite 319 verte (+3 PDF pre-existants)
Sans oublier v5.4.0 (non publie jusque-la) :
- Undo/Redo (Ctrl+Z/Ctrl+Shift+Z/Ctrl+Y), duplicate (Ctrl+D), menu de bloc (turn
into, couleurs, lien #fdblk-, move to, delete), drag&drop multi-selection,
slash "Actions", en-tetes de tableau (has_header/first_col_header) + exports,
sync realtime immédiat apres mutation ; 9 tests test_block_interactions.py
- Passerelle WebSocket WS /ws/pages/{page_id} (auth cookie, close 4401/4404), rooms par page en mémoire
- Protocole hello/sync/op/ack/title/sel/ping/peer_join/peer_leave ; version de page + stale => resync
- Merge des ops de blocs insert/update/delete/move, last-write-wins par bloc, ordre d'arrivée
- Client éditeur : diff local -> ops (debounce), application distante discret (LWW sur bloc focalisé), re-focus du bloc actif
- Présence (avatars topbar) + curseurs live (calque dédié, positions à l'édition/au scroll)
- Titre synchronisé (debounce) sans écraser le titre en cours d'édition
- Fallback polling 10 s si WS indisponible (adopté seulement sans brouillon local) + reconnexion auto
- Persistance debounce ~1 s (content/title) + flush à la déconnexion du dernier client
- CSP connect-src étendu à ws: ; peers sans données sensibles (id/login/full_name/couleur)
- 12 tests tests/test_realtime.py (auth, page absente, hello->sync, LWW 2 clients + persistance, présence, curseurs, titre, stale resync, apply_op/merge_ops) ; suite 298 verte (3 PDF pré-existants)
- Bump v5.3.0 (VERSION, main.py, CHANGELOG) ; ROADMAP v5.13.0 livré
Analyse le contenu collé dans un bloc (paste2b) et le répartit en blocs selon
son format : titres markdown, listes à puces et numérotées, cases à cocher,
citations, séparateurs, blocs de code, tableaux et paragraphes. Le texte avant
le curseur est conservé dans le bloc courant (réutilisé si vide), les nouveaux
blocs sont insérés après, le texte après le curseur garde sa place, refocus sur
le dernier bloc inséré. Les blocs non textuels (code, tableau, image...) gardent
un collage en texte brut.
- La boite d'edition inline est desormais large comme le nom du fichier
(largeur mesuree a l'ouverture via _fitNameWidth, min 60px, bornee a
l'espace disponible pour les tres longs noms — le texte defile dedans).
- Le bouton Open est place juste a droite de la boite (marge 6px) au lieu du
bord du panneau : deplace dans la cellule .name.file, passee en inline-flex
(overflow hidden + max-width) pour ne jamais passer a la ligne.
- Applique aux deux rendus de l'arbre (vue principale x-for + renderChildren).
- Verifie en Chrome headless : input = largeur du nom (38%/12% de la ligne au
lieu de 100%), bouton Open a 6px de la boite, pas de passage a la ligne.
- Tests : 278 passent.
Validation:
- _validate_chat_models utilise le payload de production (temperature 0.2,
sans max_tokens) : un 200 a la validation == 200 a l'usage reel.
- Race corrigee : payload reconstruit par modele (plus de noms croises entre
requetes concurrentes) — la v5.1.8 retombait par intermittence sur la liste
brute (68 modeles dont la plupart en 404/410).
- Retente une fois sur timeout/5xx (les modeles lents mais fonctionnels
survivent) ; rejette les 4xx (404 inconnu, 410 retire) ; garde les 429.
- Verifie en live contre l'API NVIDIA : 12 modeles valides au lieu de 68.
Repli runtime:
- LLMClient._http_complete : sur 404/410, retente une fois avec le modele par
defaut du provider et marque un notice dans LLMResponse.
- AgentEngine emet un evenement SSE "notice" (bandeau .fd-ap-notice dans le
panneau Agent, reset a chaque conversation) ; le modele reel est persistee.
UI:
- FAB (rond bas droite) et logo header du panneau : rond noir/blanc qui suit
le theme clair/sombre (--text-primary / --bg-primary), eclair monochrome
SVG a la place de l'emoji robot.
Tests:
- 2 nouveaux tests (429 garde / 410 retire ; repli runtime sur 410).
- 278 tests passent.
Editeur:
- sync() lisait el.textContent qui supprime les <br> : ajout de gt()
(innerText) utilise par sync(), tableaux (toggle/columns) et _resultText.
- Scission sur Entree + limites haut/bas de bloc via splitCaret() (marqueur
temporaire au curseur), plus de perte de fin de bloc multi-lignes.
Agent:
- fetch_provider_models() valide la liste nvidia : filtre des modeles
non-chat (nom) puis probe reelle /chat/completions (6 paralleles, 6s),
seuls les modeles 2xx restent. 429 conserve (route, donc utilisable).
- Repli sur la liste filtree si toutes les validations echouent.
- tests/test_llm_config.py : 5 cas (catalogue mock, 404 chat, autres
providers non valides, chute de securite, offline). 276 tests passent.
Shift+Entree insere des <br>; sync() lit textContent (des \n). mdEsc() (render
des blocs) n'encaissait pas les \n et le HTML les repliait en espace -> les
lignes disparaissaient au re-rendu apres la scission Entree.
mdEsc() convertit desormais les \n restants en <br> (apres les remplacements
markdown inline): ligne conservee pour tous les types de bloc.
v5.1.7
- Titre : data-placeholder="New Page" + bascule .empty a l'input (mecanisme des
blocs). Le placeholder s'affiche en gris pale quand le titre est vide, y compris
apres effacement. save() stocke un titre vide (plus de "New page" force), la
sidebar garde son fallback, duplicatePage retombe sur "New Page".
- Blocs : les placeholders ("Press 'space' for AI...", "Heading 1", "List"...)
passent sous :focus-within -> ne s'affichent que quand le bloc est focalise.
Idem pour "Type code..." du bloc code.
v5.1.6
La classe .empty (qui pilote les placeholders "Press 'space' for AI or '/' for
commands", "Heading 1", "List", etc.) n'etait basculee qu'au render(). Pendant
la frappe, l'evenement input mettait a jour dirty/autoSave mais jamais .empty :
le texte d'aide restait visible sur un bloc plein.
Le listener input des blocs bascule desormais .empty en direct : texte saisi ->
placeholder disparait; bloc vide -> placeholder reapparait.
v5.1.5
- Placeholder "Press 'space'..." : la classe .empty est basculee sur l'element
interne [data-bid], mais la regle CSS ciblait .block-content (l'exterieur),
donc le ::before ne se rendait jamais. La regle cible desormais
.block-content [data-bid].empty::before.
- Fleches haut/bas du menu / : ajout d'un ecouteur keydown au niveau document
(actif quand le menu est ouvert) pour naviguer quel que soit le focus
(l'input vit dans #_slashMenu, voisin de #_blocksCt). Selection .selected en
!important pour gagner sur :hover + auto-scroll de l'element selectionne.
- Item "callout" du menu / : icone texte 'lightbulb' remplacee par l'emoji
(et 'image' par ). Le texte n'est plus mal positionne.
- Composer "Edit with AI" : largeur alignee sur celle du bloc (min 280px).
v5.1.4
- AI (space) -> 400 DeepSeek: fdAgent.generate now uses the clean no-tools
/api/agent/generate endpoint instead of the tool-enabled conversation engine
(which sends tools/tool_choice schemas DeepSeek rejects). Fixes the composer,
Ask AI, AI meeting note and summarize flows.
- Slash menu arrows: selection highlight was invisible on open (nothing marked),
so up/down appeared dead. Call _rs() on open + after filtering, and give the
selected item an accent background + left bar. Verified with jsdom harness:
Down/Up move selection, Enter applies.
- Block outline when selected: suppress focus rings (outline/box-shadow) on all
editable blocks ([data-bid], .block-content, page title).
- Drag handle (6-dot): add .block-wrapper:hover/:focus-within .block-handle so
it shows on hover for normal blocks (only column-wrappers showed it before).
- Empty-block hint: brighter (--text-secondary, opacity .75) so the
"Press space for AI or / for commands" text is clearly visible on empty paras.
- VERSION + app.main -> 5.1.3; CHANGELOG updated.
- 271 tests pass; page renders 200; /api/agent/generate route registered;
JS validated (node --check + jsdom).