Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
840d2b2615 | ||
|
|
ab6ac1e84c |
@@ -1,5 +1,75 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## v7.37.0 (2026-10-01) — A20 : htmx allowEval off + plan Alpine CSP (phase 3)
|
||||
|
||||
### Changed
|
||||
|
||||
- **htmx `allowEval: false`** dans le `<meta name="htmx-config">` : htmx
|
||||
ne peut plus évaluer de JS (`hx-on`/`hx-vars`/`hx-vals`) — grep = **0
|
||||
usage** dans les templates, donc zéro régression possible. `unsafe-eval`
|
||||
reste **uniquement** pour Alpine standard.
|
||||
- **Gate E20 renforcé** : le smoke vérifie désormais que `Alpine.$data()`
|
||||
lie un vrai composant `[x-data]` de la page (le lien composant = le cœur
|
||||
de tout basculement CSP).
|
||||
|
||||
### Notes — A20 phase 3 (unsafe-eval, scopé par probes)
|
||||
|
||||
Le build `@alpinejs/csp` a été **testé empiriquement** (fichier 72 Ko,
|
||||
**0 `eval`/`new Function`**, parseur d'expressions maison) : il tourne sous
|
||||
CSP strict, mais **bloqué sur FlowDeck** par deux familles d'usages :
|
||||
- **13 expressions non parsables** par la grammaire restreinte :
|
||||
arrows (`$nextTick(() => …)` ×2), `typeof` ×1, `new Date(…)` ×4,
|
||||
optional-chaining `?.` ×6 (base, library, local_workspace, settings,
|
||||
gitea_workspace) ;
|
||||
- **24 `x-html` réactifs** (icônes SVG `getSvgIcon`/`_fileIcon`,
|
||||
markdown agent, preview) — **interdits par le build CSP** (innerHTML) :
|
||||
nécessitent de reposer les icônes sur des composants `Alpine.data`.
|
||||
- Portée : 0 variable globale/`document` accessible dans les expressions
|
||||
du build CSP (scope = données du composant + magics) → chaque site
|
||||
devient une méthode de composant enregistrée via `Alpine.data`.
|
||||
**Plan phase 3** : migrer composant par composant (library → settings →
|
||||
local_workspace → gitea → base) avec gate E2E dédiée, puis retirer
|
||||
`unsafe-eval`. En attendant : `unsafe-eval` conservé (Alpine standard).
|
||||
|
||||
## v7.36.0 (2026-10-01) — Fondations E2E + 2 bugs trouvés au passage
|
||||
|
||||
### Added
|
||||
|
||||
- **`e2e/smoke.spec.js`** — 2 gates vert contre l'instance de test :
|
||||
· **gate A39** : bascule de vues d'une collection (clic onglet Calendar →
|
||||
`?view_type=calendar`, grille `.calendar` + `.cal-header` rendue,
|
||||
collection créée puis **supprimée** = répétable)
|
||||
· **gate A20** : palette Ctrl+K (ouverture Alpine `.open`, recherche GET
|
||||
rend `.cmd-palette-item`, fermeture Échap)
|
||||
· **filet console** : 0 erreur JS/CSP (les violations atterrissent ici ;
|
||||
le bruit `Failed to load resource` 401/403 est filtré)
|
||||
· **Service Workers bloqués** dans le smoke : `/sw.js` sert sa page
|
||||
« hors ligne » sur les navigations redirigées (`redirect:'manual'`) —
|
||||
bruit PWA hors sujet, `pwa_offline.spec.js` couvre le SW
|
||||
· bootstrap autonome : login OU création du compte e2e documenté,
|
||||
workspace si absent — lecture seule sur les données existantes
|
||||
- Commande : `cd e2e && node node_modules/@playwright/test/cli.js test smoke.spec.js`
|
||||
|
||||
### Fixed (trouvés par les gates)
|
||||
|
||||
- **Bascule de vues standalone jamais fonctionnelle** : les onglets
|
||||
émettaient `?view=…` mais la route lit `view_type` (FastAPI) → l'onglet
|
||||
restait sur Table quel que soit le clic (bug pré-existant, non introduit
|
||||
par A28). Onglets → `?view_type=` + assertion comportementale ajoutée à
|
||||
`test_all_view_tabs_present`
|
||||
- **Inter bloqué par la CSP depuis v7.27** : `app.css` importait encore
|
||||
Google Fonts (`@import` raté par le grep de v7.27) → violation
|
||||
`style-src` sur chaque page + police tombée en fallback. Inter
|
||||
**auto-hébergé** : 2 faces variables (100-900, latin + latin-ext) dans
|
||||
`static/fonts/`, `@import` supprimé
|
||||
|
||||
### Notes
|
||||
|
||||
- Suite complète : **1093/1093** · ruff OK · E2E **2/2**
|
||||
- Portes : A20 (unsafe-eval) attaquable avec ce filet ; A39 couvre la
|
||||
bascule collection (pas la bascule htmx board → décision « rien »
|
||||
maintenue) ; A38 twins reste conditionné à une couverture élargie
|
||||
|
||||
## v7.35.0 (2026-10-01) — Audit : A35 TERMINÉ (Python 3.13 aligné + rebuild)
|
||||
|
||||
### Changed
|
||||
|
||||
+18
-2
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,6 +1,6 @@
|
||||
# WORKLOAD — FlowDeck Notion Clone
|
||||
|
||||
> **Début**: 2026-07-08 | **Version**: v7.35.0 (audit — A35 TERMINÉ : Python 3.13 aligné + rebuild image validé | **Statut**: EN COURS 🔄
|
||||
> **Début**: 2026-07-08 | **Version**: v7.37.0 (A20 htmx allowEval off + probe Alpine CSP = plan phase 3 (13 exprs + 24 x-html)) | **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
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.35.0",
|
||||
version="7.37.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
|
||||
@@ -71,14 +71,16 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
|
||||
# requête — un XSS injecté dans une page ne tourne plus). Les 74 handlers
|
||||
# inline `onclick=` restent couverts par `script-src-attr`, explicitement
|
||||
# détaché de script-src (sinon le nonce les désactiverait aussi).
|
||||
# `unsafe-eval` reste : Alpine (x-data en string) et htmx (hx-vars) en ont
|
||||
# besoin → à retirer avec le build CSP d'Alpine (voir reste d'A20).
|
||||
# `unsafe-eval` : Alpine STANDARD (x-data) en a besoin. htmx n'y touche
|
||||
# plus (`allowEval: false` dans le meta htmx-config — 0 hx-on/hx-vars).
|
||||
# Retrait = A20 phase 3 : build `@alpinejs/csp` (testé : 0 eval, OK sur
|
||||
# probe) mais bloqué par 13 expressions non parsables (arrows/typeof/new/
|
||||
# ?.) + 24 `x-html` réactifs (icônes SVG) → refonte des composants en
|
||||
# Alpine.data — voir ROADMAP.
|
||||
CSP_VALUE = (
|
||||
"default-src 'self'; "
|
||||
# A20 phase 2 : chart.js/leaflet vendorisés dans /static/js/vendor
|
||||
# (test_csp_no_cdn_and_vendor), plus aucun hôte CDN tiers.
|
||||
# `unsafe-eval` reste : Alpine (x-data) + htmx (hx-on/hx-vars) en
|
||||
# ont besoin → retrait avec le build CSP d'Alpine (reste d'A20).
|
||||
"script-src 'self' 'unsafe-eval' 'nonce-{nonce}'; "
|
||||
"script-src-attr 'unsafe-inline'; "
|
||||
# ponytail: aucun @font-face Google (grep négatif) → les deux
|
||||
|
||||
@@ -55,17 +55,17 @@ h1{{font-size:24px;margin:0 0 8px}} p.desc{{color:#A0A0A0;margin:0 0 20px}}
|
||||
</style></head><body>
|
||||
<h1>{icon} {title}</h1>
|
||||
<div class="view-tabs">
|
||||
<a class="tab{' active' if view_type=='table' else ''}" href="?view=table">📊 Table</a>
|
||||
<a class="tab{' active' if view_type=='board' else ''}" href="?view=board">📋 Board</a>
|
||||
<a class="tab{' active' if view_type=='calendar' else ''}" href="?view=calendar">📅 Calendar</a>
|
||||
<a class="tab{' active' if view_type=='gallery' else ''}" href="?view=gallery">🖼️ Gallery</a>
|
||||
<a class="tab{' active' if view_type=='list' else ''}" href="?view=list">📝 List</a>
|
||||
<a class="tab{' active' if view_type=='timeline' else ''}" href="?view=timeline">📈 Timeline</a>
|
||||
<a class="tab{' active' if view_type=='gantt' else ''}" href="?view=gantt">📊 Gantt</a>
|
||||
<a class="tab{' active' if view_type=='chart' else ''}" href="?view=chart">📉 Chart</a>
|
||||
<a class="tab{' active' if view_type=='form' else ''}" href="?view=form">📋 Form</a>
|
||||
<a class="tab{' active' if view_type=='map' else ''}" href="?view=map">🗺️ Map</a>
|
||||
<a class="tab{' active' if view_type=='feed' else ''}" href="?view=feed">📰 Feed</a>
|
||||
<a class="tab{' active' if view_type=='table' else ''}" href="?view_type=table">📊 Table</a>
|
||||
<a class="tab{' active' if view_type=='board' else ''}" href="?view_type=board">📋 Board</a>
|
||||
<a class="tab{' active' if view_type=='calendar' else ''}" href="?view_type=calendar">📅 Calendar</a>
|
||||
<a class="tab{' active' if view_type=='gallery' else ''}" href="?view_type=gallery">🖼️ Gallery</a>
|
||||
<a class="tab{' active' if view_type=='list' else ''}" href="?view_type=list">📝 List</a>
|
||||
<a class="tab{' active' if view_type=='timeline' else ''}" href="?view_type=timeline">📈 Timeline</a>
|
||||
<a class="tab{' active' if view_type=='gantt' else ''}" href="?view_type=gantt">📊 Gantt</a>
|
||||
<a class="tab{' active' if view_type=='chart' else ''}" href="?view_type=chart">📉 Chart</a>
|
||||
<a class="tab{' active' if view_type=='form' else ''}" href="?view_type=form">📋 Form</a>
|
||||
<a class="tab{' active' if view_type=='map' else ''}" href="?view_type=map">🗺️ Map</a>
|
||||
<a class="tab{' active' if view_type=='feed' else ''}" href="?view_type=feed">📰 Feed</a>
|
||||
</div>
|
||||
{body}
|
||||
</body></html>"""
|
||||
@@ -130,9 +130,9 @@ def _render_calendar(view_type: str, collection: dict, pages: list[dict], config
|
||||
.cal-nav span{{font-size:16px;font-weight:600}}
|
||||
</style>
|
||||
<div class="cal-nav">
|
||||
<a href="?view=calendar&year={prev.year}&month={prev.month}">← {prev.strftime('%B')}</a>
|
||||
<a href="?view_type=calendar&year={prev.year}&month={prev.month}">← {prev.strftime('%B')}</a>
|
||||
<span>{first.strftime('%B %Y')}</span>
|
||||
<a href="?view=calendar&year={next_month.year}&month={next_month.month}">{next_month.strftime('%B')} →</a>
|
||||
<a href="?view_type=calendar&year={next_month.year}&month={next_month.month}">{next_month.strftime('%B')} →</a>
|
||||
</div>
|
||||
<div class="calendar">
|
||||
<div class="cal-header">Mon</div><div class="cal-header">Tue</div><div class="cal-header">Wed</div>
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
</style>
|
||||
{# A20 : htmx copie les <script nonce="{{ csp_nonce() }}"> des réponses boostées — il remet leur nonce
|
||||
depuis cette config (le nonce de la réponse courante, pas celui du fetch). #}
|
||||
<meta name="htmx-config" content='{"inlineScriptNonce": "{{ csp_nonce() }}"}'>
|
||||
<meta name="htmx-config" content='{"inlineScriptNonce": "{{ csp_nonce() }}", "allowEval": false}'>
|
||||
<script nonce="{{ csp_nonce() }}">
|
||||
// A38 : helper CSRF unique — défini le plus tôt possible (head) pour
|
||||
// tous les scripts inline/externes de la page (welcome.html, isolé de
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "FlowDeck",
|
||||
"version": "7.35.0"
|
||||
"version": "7.37.0"
|
||||
},
|
||||
"paths": {
|
||||
"/auth/register": {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
/**
|
||||
* Smoke E2E — fondations vérifiant les portes des reports d'audit :
|
||||
* - A39 : bascule de vues (création d'une vue Board depuis la barre de
|
||||
* vues d'une collection → rendu de la grille)
|
||||
* - A20 : Alpine + palette de commandes (Ctrl+K, recherche GET, fermeture)
|
||||
* - filet : 0 erreur console (les violations CSP atterrissent ici)
|
||||
*
|
||||
* READ-ONLY sur les données existantes : crée puis SUPPRIME sa collection
|
||||
* (répétable). Instance de test attendue sur FD_BASE_URL (défaut 8080).
|
||||
*/
|
||||
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';
|
||||
|
||||
// Service Workers BLOQUÉS : /sw.js sert sa page « hors ligne » quand la
|
||||
// réponse de navigation est une redirection (redirect:'manual' sur les
|
||||
// requêtes navigate) — bruit PWA hors sujet ici (pwa_offline.spec.js
|
||||
// couvre le SW). On interroge le serveur directement.
|
||||
test.use({ serviceWorkers: 'block' });
|
||||
|
||||
const consoleErrors = [];
|
||||
test.beforeEach(async ({ page }) => {
|
||||
consoleErrors.length = 0;
|
||||
page.on('console', (m) => {
|
||||
if (m.type() !== 'error') return;
|
||||
// les 401 de ressources (checks de session sur login) sont du bruit
|
||||
// navigateur, pas une erreur JS/CSP — le reste compte
|
||||
if (/Failed to load resource/.test(m.text())) return;
|
||||
consoleErrors.push(m.text());
|
||||
});
|
||||
page.on('pageerror', (e) => consoleErrors.push('pageerror: ' + e.message));
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
// Aucune erreur JS/CSP pendant le scénario
|
||||
expect(consoleErrors).toEqual([]);
|
||||
});
|
||||
|
||||
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 Promise.race([
|
||||
page
|
||||
.waitForURL('**/workspaces', { timeout: 8000 })
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
]);
|
||||
if (!ok) {
|
||||
// Compte absent de l'instance de test → création (bootstrap du harness,
|
||||
// pas un mot de passe deviné : c'est le compte e2e documenté du repo).
|
||||
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
|
||||
data: { email: USER, password: PASS, name: 'E2E' },
|
||||
});
|
||||
if (resp.status() === 409) {
|
||||
throw new Error(
|
||||
'compte e2e existant mais mot de passe refusé — définir FD_USER/FD_PASS'
|
||||
);
|
||||
}
|
||||
if (!resp.ok()) {
|
||||
throw new Error(`register ${resp.status()}: ${await resp.text()}`);
|
||||
}
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForURL('**/workspaces', { timeout: 15000 });
|
||||
}
|
||||
// workspace requis pour créer une collection (compte neuf = aucun ws)
|
||||
const ws = await page.evaluate(async () => (await fetch('/api/workspaces')).json());
|
||||
if (!ws.workspaces || ws.workspaces.length === 0) {
|
||||
await page.evaluate(async () => {
|
||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
const r = await fetch('/api/workspaces', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
|
||||
body: JSON.stringify({ name: 'E2E workspace' }),
|
||||
});
|
||||
const w = await r.json();
|
||||
await fetch(`/api/workspaces/${w.id}/select`, { method: 'POST' });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('gate A39 : bascule de vues (table → Calendar, rendu par onglet)', async ({ page }) => {
|
||||
await login(page);
|
||||
|
||||
// collection jetable (créée puis supprimée = répétable). /db/{id} est une
|
||||
// page STANDALONE (hors base.html) : les onglets .view-tabs naviguent en
|
||||
// ?view=… et le corps est rendu côté serveur par _render_view().
|
||||
const coll = await page.evaluate(async () => {
|
||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
const r = await fetch('/db/api', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
|
||||
body: JSON.stringify({ name: 'e2e-view-switch' }),
|
||||
});
|
||||
return r.json();
|
||||
});
|
||||
expect(coll.id, `création collection: ${JSON.stringify(coll)}`).toBeTruthy();
|
||||
try {
|
||||
await page.goto(`${FD_BASE}/db/${coll.id}`, { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('.view-tabs a.tab')).toHaveCount(11);
|
||||
await expect(page.locator('.calendar')).toHaveCount(0); // vue table par défaut
|
||||
|
||||
// bascule réelle : clic sur l'onglet Calendar → navigation ?view=calendar
|
||||
await page.click('.view-tabs a.tab:has-text("Calendar")');
|
||||
await page.waitForURL(/view_type=calendar/, { timeout: 10000 });
|
||||
await expect(page.locator('.view-tabs a.tab.active')).toContainText('Calendar');
|
||||
// corps Calendar rendu par _render_calendar (grille 6×7)
|
||||
await expect(page.locator('.calendar')).toHaveCount(1);
|
||||
expect(await page.locator('.cal-header').count()).toBeGreaterThanOrEqual(7);
|
||||
} finally {
|
||||
await page.evaluate(async (id) => {
|
||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
await fetch(`/db/api/${id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf } });
|
||||
}, coll.id);
|
||||
}
|
||||
});
|
||||
|
||||
test('gate A20 : palette Ctrl+K (Alpine + recherche GET)', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
await page.keyboard.press('Control+k');
|
||||
await page.waitForTimeout(400);
|
||||
// overlay ouvert (classe .open pilotée par l'IIFE de base.html)
|
||||
const open = await page.evaluate(() => {
|
||||
const ov = document.querySelector('#fd-command-palette');
|
||||
return !!ov && ov.classList.contains('open');
|
||||
});
|
||||
expect(open).toBe(true);
|
||||
|
||||
// tape une requête → la recherche GET répond et rend des résultats
|
||||
await page.keyboard.type('a');
|
||||
await page.waitForTimeout(900);
|
||||
const items = await page.evaluate(
|
||||
() => document.querySelectorAll('.cmd-palette-item').length
|
||||
);
|
||||
expect(items).toBeGreaterThan(0);
|
||||
|
||||
// Alpine doit être lié (build CSP : le x-data + ses expressions évalués
|
||||
// SANS eval) sur un composant réel de la page
|
||||
const alpine = await page.evaluate(() => {
|
||||
const el = document.querySelector('[x-data]');
|
||||
if (!el || !window.Alpine) return 'absent';
|
||||
try {
|
||||
const data = window.Alpine.$data(el);
|
||||
return data && typeof data === 'object' ? 'ok:' + Object.keys(data).slice(0, 3).join(',') : 'vide';
|
||||
} catch (e) {
|
||||
return 'throw:' + e.message;
|
||||
}
|
||||
});
|
||||
expect(alpine).toMatch(/^ok:/);
|
||||
|
||||
// Échap ferme la palette
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
const closed = await page.evaluate(() => {
|
||||
const ov = document.querySelector('#fd-command-palette');
|
||||
return !ov || !ov.classList.contains('open');
|
||||
});
|
||||
expect(closed).toBe(true);
|
||||
});
|
||||
+83
-1
@@ -3,7 +3,89 @@
|
||||
Référence: Notion Light Mode (par défaut)
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
/* Inter auto-hébergé — remplace l'@import Google Fonts que la CSP bloque
|
||||
(style-src sans fonts.googleapis depuis v7.27). Police variable v20 :
|
||||
une face par sous-ensemble, font-weight 100-900. */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-latin-ext.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-400-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-500-latin-ext.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-500-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-600-latin-ext.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-600-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-700-latin-ext.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-700-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
|
||||
/* ===== LIGHT THEME (default) ===== */
|
||||
:root {
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+2
-2
@@ -8,8 +8,8 @@
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
'use strict';
|
||||
|
||||
const CACHE_NAME = 'flowdeck-v6';
|
||||
const DATA_CACHE = 'flowdeck-data-v6';
|
||||
const CACHE_NAME = 'flowdeck-v7'; // v7 : A20 (htmx allowEval off, Inter auto-hébergé)
|
||||
const DATA_CACHE = 'flowdeck-data-v7';
|
||||
|
||||
// App shell (assets versionnés comme référencés dans les templates).
|
||||
const PRECACHE_URLS = [
|
||||
|
||||
+6
-1
@@ -2554,7 +2554,12 @@ def test_all_view_tabs_present(client):
|
||||
resp = client.get(f"/db/{coll_id}/view/table")
|
||||
assert resp.status_code == 200
|
||||
for vt in ["table", "board", "calendar", "gallery", "list", "timeline", "gantt", "chart", "form", "map", "feed"]:
|
||||
assert f"?view={vt}" in resp.text, f"Missing view tab: {vt}"
|
||||
# A39/E2E : le nom du paramètre doit être `view_type` (celui de la
|
||||
# route) — `?view=` était ignoré et l'onglet restait sur Table.
|
||||
assert f"?view_type={vt}" in resp.text, f"Missing view tab: {vt}"
|
||||
# et la query commute réellement la vue rendue
|
||||
resp = client.get(f"/db/{coll_id}?view_type=calendar")
|
||||
assert 'class="calendar"' in resp.text
|
||||
|
||||
|
||||
def test_view_unknown_falls_back_to_table(client):
|
||||
|
||||
@@ -226,7 +226,8 @@ def _assert_nonce(csp: str, html: str) -> str:
|
||||
assert nm, script_src
|
||||
nonce = nm.group(1)
|
||||
assert "'unsafe-inline'" not in script_src, script_src
|
||||
assert "'unsafe-eval'" in script_src # Alpine/htmx — reste d'A20
|
||||
assert "'unsafe-eval'" in script_src # reste A20 phase 3 (Alpine standard,
|
||||
# build CSP bloqué : 13 exprs non parsables + 24 x-html réactifs → ROADMAP)
|
||||
assert "script-src-attr 'unsafe-inline'" in csp
|
||||
tags = [
|
||||
mm.group(0)
|
||||
|
||||
Reference in New Issue
Block a user