Compare commits

...
3 Commits
Author SHA1 Message Date
bruno aa88cf6665 fix: anti-FOUC au chargement complet (x-cloak sur .app-layout) + nettoyage (v7.45.4)
FlowDeck CI / docker (push) Successful in 1m52s
FlowDeck CI / lint (push) Successful in 2m15s
FlowDeck CI / test (push) Successful in 15m22s
- Symptôme rapporté : clic sur Home → rafale de menus/fenêtres + bande rouge
  « You are offline. Changes will sync when connection is restored. ».
- Constat : le masquage de v7.45.3 ne couvrait QUE les swaps partiels fdLoad.
  Un chargement COMPLET de document (F5, première visite, navigation servie par
  le service worker) peignait toute l'app tant qu'Alpine n'avait pas initialisé
  .app-layout : sidebar brute (sections ouvertes, menu utilisateur) + zone brute
  dont la bande hors ligne (aucun x-cloak dessus). Le symptôme Home n'est par
  ailleurs PAS rejouable en navigateur frais (nav, clic Home en ligne/hors
  ligne, plein chargement → 0 frame de contenu brut, 0 erreur console).
- Fix : x-cloak sur le nœud racine Alpine .app-layout (base.html) — sidebar +
  zone + bandeau masqués jusqu'au montage d'appState(), retrait par Alpine à
  l'init. Le probe asserte que l'attribut est bien retiré et que le node est
  visible en fin de parcours (garde-fou contre la page blanche).
- Suppression de la classe fd-navigating (posée à chaque fdLoad, aucune règle
  CSS dans le dépôt → code mort).
- e2e/probe_nav_perf.spec.js : 4 scénarios avec assertions (nav 5 pages, clic
  Home réel, Home hors ligne avec SW, plein chargement 3 pages) + capture
  screenshot auto si contenu brut + collecte console.

Gates : probe_nav_perf 4/4 · regression_partial_nav + regression_editor_mount
+ regression_logout_dnd + smoke = 6 passed (errs=0 sur 7 pages × 3 passages) ·
pytest 1094 passed · ruff OK · OpenAPI 7.45.4.
2026-10-03 00:54:28 -04:00
bruno b2ea8ec537 docs: suivis v7.45.3 listés au ROADMAP (CLS library, fenêtre settings, topbar swappée, fd-navigating morte)
FlowDeck CI / lint (push) Successful in 1m57s
FlowDeck CI / test (push) Failing after 3h12m37s
FlowDeck CI / docker (push) Skipped
2026-10-03 00:16:52 -04:00
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
9 changed files with 406 additions and 7 deletions
+62
View File
@@ -1,5 +1,67 @@
# Changelog - FlowDeck
## v7.45.4 (2026-10-03) — Anti-FOUC au chargement complet + morts nettoyés
### Fixed
- **Flashs au chargement COMPLET** (F5, première visite, navigation servie par
le service worker) : le masquage `x-cloak`/`opacity` de v7.45.3 ne couvrait
que les swaps partiels fdLoad — un chargement de document peignait la page
tant qu'Alpine n'avait pas initialisé `.app-layout`, donc sidebar brute (toutes
sections ouvertes, menu utilisateur visible) **et** contenu de la zone dont la
bande rouge « You are offline. Changes will sync when connection is
restored. » (aucun `x-cloak` sur elle). Fix : `x-cloak` sur le nœud racine
Alpine `.app-layout` (base.html) — tout l'app (sidebar + zone + bandeau) reste
masqué tant que `appState()` n'est pas monté, puis Alpine retire l'attribut.
Vérifié par le probe (3 pages × plein chargement) : 0 frame de contenu brut,
`x-cloak` retiré et `display:flex` à la fin (donc pas de page blanche).
### Changed
- **`fd-navigating` supprimée** : classe posée/retirée à chaque `fdLoad` mais
sans aucune règle CSS dans le dépôt (grep css/js/html → seulement app.js) —
code mort.
- **`e2e/probe_nav_perf.spec.js` étendu à 4 scénarios avec assertions** :
navigations partielles (5 pages), **clic réel sur le bouton Home**, Home avec
réseau coupé (SW actif), et plein chargement. Asserte : 0 frame de contenu
brut peint, zone révélée (`opacity:1`) et montée (`0 [x-show]` non montés,
`x-ignore` absent), `.app-layout` sans `x-cloak` et visible. Capture
screenshot automatique si du contenu brut est peint + collecte console.
### Not reproduced
- Le symptôme rapporté (Home → rafale de menus/fenêtres + bande rouge) n'est
**pas rejouable** dans un navigateur frais : clic Home en ligne comme hors
ligne, nav partielle et plein chargement donnent 0 frame de contenu brut et 0
erreur console. Reste à confirmer côté navigateur signalé (recharge dure
après deploy, URL/instance testée).
## 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
+3
View File
@@ -1004,6 +1004,9 @@ 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).
- Suivis restants (mesurés, non traités) : CLS résiduel **0.058** sur /library (états `lib-loading`/`lib-empty` → `lib-table` : le remplacement du spinner par les lignes décale le contenu — nécessiterait un squelette de lignes, pas un réglage CSS) · fenêtre `x-ignore` de **~150–300 ms** sur /settings (coût des scripts de page avant `initTree`, pas de gain sans changer l'ordre de montage) · la **topbar est dans `.main-wrapper`** (base.html) → re-swappée et re-montée à chaque navigation (avec le masquage elle disparaît/revient au lieu de flasher — la sortir demanderait de la swapper séparément).
- Suite v7.45.4 : `x-cloak` posé sur `.app-layout` (racine Alpine) → les **chargements complets** (F5, SW, hors ligne) ne peignent plus la sidebar brute ni la bande rouge hors ligne — le masquage fdLoad ne couvrait que les swaps partiels. `fd-navigating` (classe sans règle CSS) supprimée. Probe étendu à 4 scénarios (nav / clic Home réel / Home hors ligne / plein chargement) avec assertions. **Symptôme Home non rejouable** en navigateur frais (0 frame brut, 0 erreur console) → à confirmer sur le navigateur signalé.
### 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.4
+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.4 (anti-FOUC : `x-cloak` sur `.app-layout` = plus de sidebar brute ni de bande rouge hors ligne au chargement complet, classe morte `fd-navigating` supprimée, probe étendu à 4 scénarios avec assertions) | **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.4",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
+1 -1
View File
@@ -130,7 +130,7 @@
<script src="/static/js/sortable.min.js" defer data-cfasync="false"></script>
</head>
<body hx-headers='{"X-CSRF-Token":{{ csrf_token() | tojson }}}'{% if embed_mode %} class="embed-mode"{% endif %}>
<div class="app-layout" x-data="appState()">
<div class="app-layout" x-data="appState()" x-cloak>
<!-- ═══════════ MOBILE SIDEBAR OVERLAY ═══════════ -->
<div class="sidebar-overlay"
+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
"version": "7.45.2"
"version": "7.45.4"
},
"paths": {
"/auth/register": {
+322
View File
@@ -0,0 +1,322 @@
/**
* 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', '/local-workspace'];
const results = [];
const SHOTS = require('path').join(__dirname, 'shots');
require('fs').mkdirSync(SHOTS, { recursive: true });
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);
// Filet : si du contenu brut est peint, on capture pour voir CE qui flash
let shot = null;
const tEnd = Date.now() + 3200;
while (Date.now() < tEnd && !shot) {
const last = await page.evaluate(() => {
const f = window.__probe.frames;
return f.length ? f[f.length - 1] : null;
});
if (last && last[2] > 0) {
shot = `navflash_${dest.replace(/\W+/g, '')}.png`;
await page.screenshot({ path: require('path').join(SHOTS, shot) });
break;
}
await page.waitForTimeout(25);
}
const P = await page.evaluate(() => window.__probe);
// État final : la zone doit être révélée (opacity>0) ET montée Alpine,
// et .app-layout (x-cloak anti-FOUC) doit être retiré → pas de page blanche
const final = await page.evaluate(() => {
const z = document.querySelector('.main-wrapper');
const a = document.querySelector('.app-layout');
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'),
layout: a ? { cloak: a.hasAttribute('x-cloak'), display: getComputedStyle(a).display } : 'ABSENT',
};
});
// 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
|| !r.final.layout || r.final.layout.cloak === true || r.final.layout.display === 'none');
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');
});
// Le geste réel du rapport : clic sur le bouton Home de la sidebar (et non un
// ancre synthétique) — on mesure ce qui peint et on garde une capture si flash.
test('probe bouton Home', async ({ page }) => {
test.setTimeout(120000);
const SHOTS = require('path').join(__dirname, 'shots');
require('fs').mkdirSync(SHOTS, { recursive: true });
await login(page);
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'load' });
await page.waitForTimeout(1200);
await page.evaluate(INSTALL);
const console_ = [];
page.on('console', (m) => { if (m.type() !== 'log') console_.push(m.type() + ' | ' + m.text().slice(0, 180).replace(/\s+/g, ' ')); });
page.on('pageerror', (e) => console_.push('PAGEERROR | ' + String(e).slice(0, 180)));
const before = await page.evaluate(() => ({ onLine: navigator.onLine, home: !!document.querySelector('.home-btn'), href: (document.querySelector('.home-btn') || {}).href }));
console.log('HOME avant clic ' + JSON.stringify(before));
await page.click('.home-btn');
let shot = null;
const tEnd = Date.now() + 4000;
while (Date.now() < tEnd && !shot) {
const last = await page.evaluate(() => {
const f = window.__probe.frames;
return f.length ? f[f.length - 1] : null;
});
if (last && last[2] > 0) {
shot = 'home_flash.png';
await page.screenshot({ path: require('path').join(SHOTS, shot) });
break;
}
await page.waitForTimeout(25);
}
await page.waitForTimeout(2000);
const P = await page.evaluate(() => window.__probe);
const rawFrames = P.frames.filter((f) => f[2] > 0);
const after = await page.evaluate(() => {
const b = document.querySelector('.offline-banner');
const z = document.querySelector('.main-wrapper');
return {
url: location.pathname + location.search,
onLine: navigator.onLine,
banner: b ? { cloak: b.hasAttribute('x-cloak'), display: getComputedStyle(b).display, mounted: !!b._x_marker } : 'absente',
zone: z ? { op: getComputedStyle(z).opacity, un: Array.from(z.querySelectorAll('[x-show]')).filter((e) => !e._x_marker).length, xIgnore: z.hasAttribute('x-ignore') } : null,
mounts: typeof window.__fdLocalWorkspaceScriptsLoaded !== 'undefined' ? window.__fdLocalWorkspaceScriptsLoaded : 'n/a',
};
});
const res = { marks: Object.fromEntries(P.marks), rawVisibleFrames: rawFrames.length, rawFirst: rawFrames[0] || null, rawMax: P.rawMax, shot, shifts: P.shifts, console: console_, after };
console.log('HOME résultat => ' + JSON.stringify(res));
if (res.rawVisibleFrames > 0) throw new Error(`contenu brut peint au clic Home: ${res.rawVisibleFrames} frames (max ${res.rawMax})`);
console.log('GATE HOME OK — 0 frame de contenu brut');
});
// Scénario du rapport : la bande rouge dit « hors ligne » → on rejoue le clic
// Home AVEC le réseau coupé (SW actif) pour voir ce qui peint.
test('probe bouton Home hors ligne', async ({ page, context }) => {
test.setTimeout(120000);
const SHOTS = require('path').join(__dirname, 'shots');
require('fs').mkdirSync(SHOTS, { recursive: true });
await login(page);
await page.addInitScript(INSTALL);
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'load' });
await page.waitForTimeout(1500); // laisse le SW s'installer et cacher les pages
await page.evaluate(() => { window.__probe = null; window.__probe = undefined; });
await page.evaluate(INSTALL);
await context.setOffline(true);
await page.waitForTimeout(200);
const onLine = await page.evaluate(() => navigator.onLine);
await page.click('.home-btn');
const shots = [];
for (const [delay, name] of [[700, 't700'], [2500, 't2500']]) {
await page.waitForTimeout(delay === 700 ? 700 : 1800);
const p = `home_offline_${name}.png`;
await page.screenshot({ path: require('path').join(SHOTS, p) }).catch(() => {});
shots.push(p);
}
const P = await page.evaluate(() => window.__probe || null).catch(() => null);
const after = await page.evaluate(() => {
const b = document.querySelector('.offline-banner');
const z = document.querySelector('.main-wrapper');
const visible = (sel) => Array.from(document.querySelectorAll(sel)).filter((e) => e.getClientRects().length && getComputedStyle(e).display !== 'none').map((e) => (e.className || '').toString().split(' ')[0]).slice(0, 12);
return {
url: location.pathname + location.search,
onLine: navigator.onLine,
banner: b ? { display: getComputedStyle(b).display, mounted: !!b._x_marker } : 'absente',
zone: z ? { op: getComputedStyle(z).opacity, un: Array.from(z.querySelectorAll('[x-show]')).filter((e) => !e._x_marker).length, xIgnore: z.hasAttribute('x-ignore') } : null,
overlays: visible('.modal-overlay, .ctx-menu, .context-menu, .user-menu-dropdown, .dropdown-menu, .popover'),
};
}).catch((e) => ({ err: String(e) }));
const rawFrames = P ? P.frames.filter((f) => f[2] > 0) : [];
const res = { onLineBefore: onLine, marks: P ? Object.fromEntries(P.marks) : null, rawVisibleFrames: rawFrames.length, rawFirst: rawFrames[0] || null, rawMax: P ? P.rawMax : null, shots, shifts: P ? P.shifts : null, after };
console.log('HOME OFFLINE => ' + JSON.stringify(res));
});
// Charge COMPLÈTE (F5 / premier visit / SW) : le masquage fdLoad ne s'applique
// pas là, seul x-cloak protège — on mesure ce qui peint avant Alpine.start().
test('probe fluidité plein chargement', async ({ page }) => {
test.setTimeout(120000);
const SHOTS = require('path').join(__dirname, 'shots');
require('fs').mkdirSync(SHOTS, { recursive: true });
await login(page);
await page.addInitScript(INSTALL);
const OUT = [];
for (const dest of ['/local-workspace', '/library', '/workspaces']) {
await page.goto(FD_BASE + dest, { waitUntil: 'commit' });
let shot = null;
const tEnd = Date.now() + 4000;
while (Date.now() < tEnd && !shot) {
const last = await page.evaluate(() => {
const f = window.__probe && window.__probe.frames;
return f && f.length ? f[f.length - 1] : null;
}).catch(() => null);
if (last && last[2] > 0) {
shot = `fullflash_${dest.replace(/\W+/g, '')}.png`;
await page.screenshot({ path: require('path').join(SHOTS, shot) });
break;
}
await page.waitForTimeout(25);
}
await page.waitForTimeout(1500);
const P = await page.evaluate(() => window.__probe);
const rawFrames = P.frames.filter((f) => f[2] > 0);
const res = {
dest,
rawVisibleFrames: rawFrames.length,
rawFirst: rawFrames[0] || null,
rawLast: rawFrames[rawFrames.length - 1] || null,
rawMax: P.rawMax,
shot,
shifts: P.shifts,
final: await page.evaluate(() => {
const z = document.querySelector('.main-wrapper');
const a = document.querySelector('.app-layout');
return z ? {
op: getComputedStyle(z).opacity,
un: Array.from(z.querySelectorAll('[x-show]')).filter((e) => !e._x_marker).length,
layout: a ? { cloak: a.hasAttribute('x-cloak'), display: getComputedStyle(a).display } : 'ABSENT',
} : null;
}),
};
OUT.push(res);
console.log('FULL ' + dest + ' => ' + JSON.stringify(res));
}
console.log('SOMMAIRE_FULL ' + JSON.stringify(OUT.map((r) => [r.dest, r.rawVisibleFrames, r.rawMax, r.shot])));
const notRevealed = OUT.filter((r) => !r.final || parseFloat(r.final.op) !== 1 || r.final.un !== 0
|| !r.final.layout || r.final.layout.cloak === true || r.final.layout.display === 'none');
if (notRevealed.length) throw new Error('page blanche/non montée au chargement: ' + JSON.stringify(notRevealed.map((r) => [r.dest, r.final])));
if (OUT.some((r) => r.rawVisibleFrames > 0)) {
throw new Error('contenu brut peint au plein chargement: ' + OUT.filter((r) => r.rawVisibleFrames > 0).map((r) => `${r.dest} (${r.rawVisibleFrames} frames, max ${r.rawMax})`).join(' ; '));
}
console.log('GATE FULL OK — 0 frame de contenu brut au chargement complet');
});
+14 -2
View File
@@ -283,10 +283,8 @@
if (!window.htmx) { window.location.href = url; return; }
if (loading) return;
loading = true;
document.documentElement.classList.add('fd-navigating');
function done() {
loading = false;
document.documentElement.classList.remove('fd-navigating');
}
var p;
try {
@@ -356,9 +354,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 +377,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 +390,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;
}