Compare commits

..
1 Commits
Author SHA1 Message Date
bruno 1051a72b52 fix: flashs de navigation — la zone swapée n'est plus peint non montée (v7.45.3)
FlowDeck CI / docker (push) Successful in 1m52s
FlowDeck CI / lint (push) Successful in 1m58s
FlowDeck CI / test (push) Successful in 15m25s
- Symptôme : à chaque changement de page/section, une rafale de fenêtres /
  menus / états s'affiche une fraction de seconde avant la page voulue.
- Cause : pendant la fenêtre x-ignore (swap fdLoad → Alpine.initTree, mise en
  place en v7.45.2), .main-wrapper était peint NON montée : tous ses [x-show]
  visibles à leur valeur brute, puis masqués d'un coup au montage. Mesure
  (e2e/probe_nav_perf.spec.js sur l'instance locale) : 19 éléments bruts
  visibles 87 ms sur /library (CLS 0.149, sources lib-loading/lib-empty/
  lib-table), 80 éléments pendant 732 ms sur /settings, 7 sur /workspaces.
- Fix (app.js, handler htmx:afterSwap) : opacity:0 + pointer-events:none posés
  dans la même task que le swap (aucun paint intermédiaire possible), retirés
  APRÈS Alpine.initTree sur les 3 chemins de démontage (scripts chargés, zéro
  script, filet 4 s). reveal() est appelé avant les early-returns de done() →
  impossible de rester bloqué invisible.
- Après : 0 frame de contenu brut peint sur les 4 navigations du probe,
  1–2 frames masquées, zone révélée (opacity:1, pointer-events:auto) et
  montée (0 [x-show] non montés, x-ignore absent) à chaque fois.
- Gate : e2e/probe_nav_perf.spec.js — marques htmx, fenêtre x-ignore,
  échantillon par frame des [x-show] bruts peints, layout-shift + assertion
  (0 brut / zone révélée et montée), exécutable en CI.
- Reste (non traité, voir ROADMAP) : CLS résiduel 0.057 sur /library (états
  loading → table), fenêtre x-ignore de ~250 ms sur /settings (coût des
  scripts de page), topbar incluse dans .main-wrapper re-swappée à chaque nav.

Gates : probe_nav_perf + regression_partial_nav + regression_editor_mount +
regression_logout_dnd + smoke · pytest 1094 passed · ruff OK ·
OpenAPI 7.45.3.
2026-10-03 00:15:41 -04:00
8 changed files with 191 additions and 4 deletions
+26
View File
@@ -1,5 +1,31 @@
# Changelog - FlowDeck
## v7.45.3 (2026-10-03) — Navigation partielle : le contenu brut n'est plus peint
### Fixed
- **Flashs de fenêtres / frames à chaque changement de page ou de section** :
pendant la fenêtre `x-ignore` (swap → `Alpine.initTree`), la zone
`.main-wrapper` était peinte **non montée** — tous ses `[x-show]` visibles à
leur valeur brute (menus, panneaux, états loading/empty/table superposés), puis
masqués d'un coup au montage Alpine. Mesure (`e2e/probe_nav_perf.spec.js`,
instance locale) : **19 éléments bruts visibles sur 87 ms** en arrivant sur
/library (CLS 0.149, sources `lib-loading`/`lib-empty`/`lib-table`), **80
éléments sur 732 ms** sur /settings, 7 sur /workspaces, 0 sur /trash. Fix :
`opacity: 0` + `pointer-events: none` posés **dans** le handler
`htmx:afterSwap` (même task que le swap → aucun paint intermédiaire possible)
et retirés **après** `Alpine.initTree` sur les 3 chemins de démontage (scripts
chargés, zéro script, filet 4 s). Seul le contenu monté est peint.
### Added
- **`e2e/probe_nav_perf.spec.js`** — probe de fluidité de navigation : marques
htmx (`beforeRequest`/`beforeSwap`/`afterSwap`/`afterSettle`), fenêtre
`x-ignore`, échantillon par frame du nombre d'`[x-show]` bruts encore
visibles et d'`[x-cloak]` masqués, `layout-shift` (CLS) avec sources.
`node node_modules/@playwright/test/cli.js test probe_nav_perf` (npx est cassé
sur ce poste).
## v7.45.2 (2026-10-03) — Navigation partielle : montage Alpine sans course + scripts idempotents
### Fixed
+1
View File
@@ -1004,6 +1004,7 @@ Détails livrés :
- [x] **Éditeur visuel** — **pipeline steps** dans Settings → Automations (v7.45.0) : cartes ordonnées (trigger/condition/delay/action) avec **config typée par type** (8 actions : webhook/set_property/create_page/notify/slack/email/forge_issue/agent_trigger + ops condition + datalist événements), ajout/édition/suppression/**↑↓** via l'API `/steps` + bouton **✨ Convertir le JSON en pipeline** (legacy → steps ordonnés). **Bonus trouvé par le gate** : **51 expressions multi-instructions** (`a=1; b()` — `;` = SEULE expression par directive interdit sous le parseur CSP, non couvert par le scan `tokens`) → converties en méthodes dans **11 fichiers** (nav settings ×8, menu section base ×7, partages/éditeur ×13, breadcrumb ×5, lib/local ×6, board ×3, ctx/agent/workspaces/card/gitea ×6). Nouveau scanner `scan_semi` ajouté au lot.
- [x] **Fix SW + CSRF uploads** (v7.45.1) — logout ne sert plus la page « hors ligne » (le fetch event de navigation est en `redirect:'manual'` → 302 lue `opaqueredirect` → rejet ; rejoué en `redirect:'follow'` + **302 synthétique vers l'URL finale**, Chromium refusant les responses `redirected` servies à une navigation) et drag & drop de fichiers/dossiers réparé (`_doUpload` + `toggleFavorite` sans `X-CSRF-Token` = oublis A19, derniers appels mutants du front). `timeoutFetch` annule enfin ses requêtes. Gate `e2e/regression_logout_dnd.spec.js` (2 tests verts).
- [x] **Navigation partielle Alpine** (v7.45.2) — les swaps fdLoad initialisaient `.main-wrapper` avant l'exécution des scripts de page (courses : « Undefined variable » sur l'éditeur, `reading 'has'` sur `$store.fdCtx`, `SyntaxError: 'LW' already declared` à la 2ᵉ visite). Fix maison dans `app.js` : `x-ignore` sur la zone swapée (nœud remplacé uniquement) → démontage unique à tous `<script src>` chargés via `Alpine.initTree` idempotent ; garde de fichier sur 5 scripts à `const` de haut niveau ; `if (window.Alpine)` sur 8 scripts + 7 templates inline ; polices Inter orphelines purgées d'`app.css`. Gates : `regression_editor_mount` + `regression_partial_nav` (7 pages × complet/partiel/2ᵉ = 0 erreur).
- [x] **Flashs de navigation** (v7.45.3) — pendant la fenêtre `x-ignore` (swap fdLoad → `Alpine.initTree`), la zone `.main-wrapper` était peinte **non montée** : tous ses `[x-show]` visibles à leur valeur brute (menus/panneaux/états loading+empty+table superposés) puis masqués d'un coup au montage = la rafale de fenêtres qui flashent à chaque changement de page/section. Fix 6 lignes dans `app.js` : `opacity:0` + `pointer-events:none` posés dans `htmx:afterSwap` (même task que le swap → aucun paint intermédiaire), retirés **après** `initTree` sur les 3 chemins de démontage. Mesure avant : 19 éléments bruts/87 ms sur /library (CLS 0.149), 80 éléments/732 ms sur /settings — après : 0 frame de contenu brut. Gate maison `e2e/probe_nav_perf.spec.js` (marques htmx + fenêtre x-ignore + échantillon par frame + layout-shift).
### v7.1.0 — Calendar sync + Meeting Notes ✅ (2026-09-28)
> **Objectif** : calendrier bidirectionnel + transcription → agents (cf. Notion 07/2026 : Meeting Notes trigger Custom Agents).
+1 -1
View File
@@ -1 +1 @@
7.45.2
7.45.3
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.45.2 (navigation partielle Alpine sans course = x-ignore sur `.main-wrapper` + démontage à tous scripts chargés, **5 scripts de page gardés** contre le SyntaxError de re-exécution, polices Inter orphelines purgées) | **Statut**: EN COURS 🔄
> **Début**: 2026-07-08 | **Version**: v7.45.3 (flashs de navigation = la zone `x-ignore` n'est plus peint non montée : `opacity:0` au swap, reveal après `initTree` — 0 frame de contenu brut au lieu de 19/87 ms puis 80/732 ms) | **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
+1 -1
View File
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.45.2",
version="7.45.3",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
"version": "7.45.2"
"version": "7.45.3"
},
"paths": {
"/auth/register": {
+146
View File
@@ -0,0 +1,146 @@
/**
* Probe de fluidité de navigation partielle (fdLoad) — mesure brute, pas un test gate.
*
* Pour chaque navigation il enregistre :
* - les marques htmx (beforeRequest / beforeSwap / afterSwap / afterSettle)
* - la fenêtre x-ignore (zone non montée Alpine)
* - un échantillon par frame : nb d'éléments [x-show] ENCORE VISIBLES non montés
* (= le contenu « brut » qui se voit avant qu'Alpine ne le masque → les flashes
* de fenêtres) et nb d'éléments [x-cloak] encore masqués (= contenu qui pop)
* - les layout-shift (CLS) et leur source
*
* Lancement : npx playwright test probe_nav_perf --reporter=list
*/
const { test } = require('@playwright/test');
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
async function login(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
const ok = await page.waitForURL('**/workspaces', { timeout: 8000 }).then(() => true).catch(() => false);
if (!ok) {
await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E' } });
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
await page.waitForURL('**/workspaces', { timeout: 15000 });
}
}
// Instrumentation posée UNE fois dans la page (avant le clic).
const INSTALL = () => {
const P = (window.__probe = { t0: performance.now(), marks: [], frames: [], shifts: [], rawMax: 0, rawFrames: 0 });
const mark = (n) => P.marks.push([n, Math.round(performance.now() - P.t0)]);
['htmx:beforeRequest', 'htmx:beforeSwap', 'htmx:afterSwap', 'htmx:afterSettle'].forEach((ev) =>
document.addEventListener(ev, () => mark(ev))
);
// Fenêtre x-ignore sur .main-wrapper (le nœud change à chaque swap → on observe tout le doc)
new MutationObserver((muts) => {
for (const m of muts) {
if (m.type !== 'attributes' || m.attributeName !== 'x-ignore') continue;
const t = m.target;
if (!(t instanceof Element) || !t.classList.contains('main-wrapper')) continue;
mark(t.hasAttribute('x-ignore') ? 'x-ignore ON' : 'x-ignore OFF');
}
}).observe(document.documentElement, { attributes: true, attributeFilter: ['x-ignore'], subtree: true });
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
P.shifts.push({
v: +e.value.toFixed(4),
t: Math.round(e.startTime - P.t0),
sources: (e.sources || []).map((s) => {
const n = s.node;
return n ? `${n.nodeName}.${(n.className || '').toString().split(' ')[0]}` : '?';
}),
});
}
}).observe({ type: 'layout-shift', buffered: false });
// Échantillonnage par frame. « visible » = peint à l'écran : la zone peut
// porter opacity:0 (fix v7.45.3) → rects toujours là, mais rien n'est peint.
const zonePainted = (z) => z && parseFloat(getComputedStyle(z).opacity || '1') > 0;
const tick = () => {
let raw = 0, rawVisible = 0, cloak = 0;
const zone = document.querySelector('.main-wrapper');
const painted = zonePainted(zone);
if (zone) {
for (const el of zone.querySelectorAll('[x-show]')) {
if (el._x_marker) continue; // monté par Alpine
raw++;
if (painted && el.getClientRects().length) rawVisible++; // peint ET en flow
}
cloak = zone.querySelectorAll('[x-cloak]').length;
}
P.rawMax = Math.max(P.rawMax, rawVisible);
if (rawVisible) P.rawFrames++;
P.frames.push([Math.round(performance.now() - P.t0), raw, rawVisible, cloak, painted ? 1 : 0]);
if (performance.now() - P.t0 < 3000) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
return true;
};
test('probe fluidité nav', async ({ page }) => {
test.setTimeout(120000);
await login(page);
const NAVS = ['/library', '/settings', '/workspaces', '/trash'];
const results = [];
for (const dest of NAVS) {
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'load' });
await page.waitForTimeout(1200);
await page.evaluate(INSTALL);
await page.evaluate((p) => {
const a = document.createElement('a');
a.href = p;
a.textContent = 'probe';
document.body.appendChild(a);
a.click();
}, dest);
await page.waitForTimeout(3200);
const P = await page.evaluate(() => window.__probe);
// État final : la zone doit être révélée (opacity>0) ET montée Alpine
const final = await page.evaluate(() => {
const z = document.querySelector('.main-wrapper');
if (!z) return { opacity: 'NO-ZONE', unmounted: -1, xIgnore: null };
return {
opacity: getComputedStyle(z).opacity,
pointerEvents: getComputedStyle(z).pointerEvents,
unmounted: Array.from(z.querySelectorAll('[x-show]')).filter((e) => !e._x_marker).length,
xIgnore: z.hasAttribute('x-ignore'),
};
});
// Résumé : première et dernière frame où du contenu brut visible subsiste
const rawFrames = P.frames.filter((f) => f[2] > 0);
const results_summary = {
dest,
marks: Object.fromEntries(P.marks),
rawVisibleFrames: rawFrames.length,
rawFirst: rawFrames.length ? rawFrames[0] : null,
rawLast: rawFrames.length ? rawFrames[rawFrames.length - 1] : null,
rawMaxElements: P.rawMax,
hiddenFrames: P.frames.filter((f) => f[4] === 0).length,
cloakMax: Math.max(0, ...P.frames.map((f) => f[3])),
shifts: P.shifts,
final,
frameCount: P.frames.length,
};
results.push(results_summary);
console.log('NAV ' + dest + ' => ' + JSON.stringify(results_summary));
}
// GATE : aucun contenu brut peint + zone révélée et montée à la fin
const paintedRaw = results.filter((r) => r.rawVisibleFrames > 0);
const notRevealed = results.filter((r) => parseFloat(r.final.opacity) !== 1 || r.final.unmounted !== 0 || r.final.xIgnore);
console.log('SOMMAIRE ' + JSON.stringify(results.map((r) => ({ d: r.dest, raw: r.rawVisibleFrames, hidden: r.hiddenFrames, op: r.final.opacity, un: r.final.unmounted, shift: r.shifts.length }))));
if (paintedRaw.length) throw new Error('contenu brut peint: ' + paintedRaw.map((r) => `${r.dest} (${r.rawVisibleFrames} frames, max ${r.rawMaxElements})`).join(' ; '));
if (notRevealed.length) throw new Error('zone non révélée/montée: ' + JSON.stringify(notRevealed.map((r) => [r.dest, r.final])));
console.log('GATE OK — 0 frame de contenu brut, zone révélée et montée sur ' + results.length + ' navigations');
});
+14
View File
@@ -356,9 +356,21 @@
if (!mw || mw === fdLastMw || mw.__fdSwapWatch) return;
fdLastMw = mw;
mw.__fdSwapWatch = true;
// Avant tout paint (même task que le swap) : la zone est x-ignore, donc non
// montée Alpine — tous ses [x-show] seraient peints à leur valeur brute
// (rafale de menus/panneaux/états visibles une fraction de seconde). On ne
// la peint qu'une fois initTree passé.
mw.style.opacity = '0';
mw.style.pointerEvents = 'none';
mw.setAttribute('x-ignore', '');
var pending = mw.querySelectorAll('script[src]').length;
var reveal = function () {
if (!mw.isConnected) return;
mw.style.opacity = '';
mw.style.pointerEvents = '';
};
var done = function () {
reveal(); // idempotent : appelé même si la branche suivante retourne
if (!mw.__fdSwapWatch) return;
mw.__fdSwapWatch = false;
document.removeEventListener('load', onScript, true);
@@ -367,6 +379,7 @@
mw.removeAttribute('x-ignore');
mw._x_ignore = false; // propriété posée par Alpine au checkpoint
try { Alpine.initTree(mw); } catch (e) { console.warn('[FlowDeck] mount', e); }
reveal();
};
var onScript = function (e) {
if (!e.target || e.target.tagName !== 'SCRIPT') return;
@@ -379,6 +392,7 @@
mw.__fdSwapWatch = false;
setTimeout(function () {
if (mw.isConnected) { try { Alpine.initTree(mw); } catch (e) { console.warn('[FlowDeck] mount', e); } }
reveal(); // après initTree : un paint max de blanc, jamais de contenu brut
}, 0);
return;
}