TODO — Écran pas encore développé
++ Symboles des repères, clusters et stations affichés sur la carte. +
+diff --git a/package.json b/package.json index 57620cf..7409289 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "build:qualif": "tsc -b && vite build --mode qualif && cp scripts/htaccess-qualif.example dist/.htaccess", "build:mobile": "VITE_GEODESY_SOURCE=dist tsc -b && vite build", "lint": "eslint .", + "lint:css-modules": "node scripts/check-css-modules.js", "preview": "vite preview", "preview:qualif": "vite preview --mode qualif", "generate-apk": "bash ./scripts/generate-apk.sh", diff --git a/scripts/check-css-modules.js b/scripts/check-css-modules.js new file mode 100644 index 0000000..d3da1f2 --- /dev/null +++ b/scripts/check-css-modules.js @@ -0,0 +1,613 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const PACKAGE_ROOT = path.resolve(SCRIPT_DIR, '..'); +const SRC_DIR = path.join(PACKAGE_ROOT, 'src'); +const ROOT = path.resolve(process.argv[2] || SRC_DIR); + +const SOURCE_EXTENSIONS = new Set([ + '.js', + '.jsx', + '.ts', + '.tsx', +]); + +const IGNORE_DIRS = new Set([ + 'node_modules', + '.git', + 'dist', + 'build', + 'coverage', + '.next', +]); + +const cssModules = new Map(); +const unresolvedImports = []; + +/** + * Retourne tous les fichiers d'un dossier récursivement. + */ +function walk(dir) { + if (!fs.existsSync(dir)) { + console.error(`Dossier introuvable : ${dir}`); + process.exit(1); + } + + const files = []; + + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (IGNORE_DIRS.has(entry.name)) { + continue; + } + + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + files.push(...walk(fullPath)); + } else { + files.push(fullPath); + } + } + + return files; +} + +/** + * Supprime un préfixe suivi d'un bloc équilibré, ex. :global(...) ou url("..."). + */ +function stripPrefixedBalanced(content, prefixRegex, open, close) { + let result = ''; + let lastIndex = 0; + prefixRegex.lastIndex = 0; + + let match; + + while ((match = prefixRegex.exec(content)) !== null) { + const openAt = match.index + match[0].length - 1; + let depth = 1; + let i = openAt + 1; + + while (i < content.length && depth > 0) { + const ch = content[i]; + + if (ch === open) { + depth++; + } else if (ch === close) { + depth--; + } + + i++; + } + + result += content.slice(lastIndex, match.index); + lastIndex = i; + prefixRegex.lastIndex = i; + } + + return result + content.slice(lastIndex); +} + +/** + * Supprime grossièrement les commentaires CSS. + */ +function removeCssComments(content) { + return content.replace(/\/\*[\s\S]*?\*\//g, ''); +} + +/** + * Supprime url(...) pour ne pas extraire de faux .org / .svg. + */ +function stripCssUrls(content) { + let result = content.replace( + /url\(\s*(['"])[\s\S]*?\1\s*\)/g, + 'url()' + ); + + result = stripPrefixedBalanced(result, /url\s*\(/g, '(', ')'); + + return result; +} + +/** + * Supprime :global(...) et :global { ... } (classes OpenLayers, etc.). + */ +function stripCssGlobals(content) { + let result = stripPrefixedBalanced(content, /:global\s*\(/g, '(', ')'); + result = stripPrefixedBalanced(result, /:global\s*\{/g, '{', '}'); + return result; +} + +/** + * Extrait les classes définies dans un module CSS. + * + * Ex: + * .foo {} + * .foo:hover {} + * .foo.bar {} + * + * => foo, bar + */ +function extractCssClasses(content) { + content = stripCssGlobals(stripCssUrls(removeCssComments(content))); + + const classes = new Set(); + + // On récupère les .classe + // + // Supporte notamment : + // .foo + // .foo-bar + // .foo_bar + // + const regex = /\.(-?[_a-zA-Z]+[_a-zA-Z0-9-]*)/g; + + let match; + + while ((match = regex.exec(content)) !== null) { + classes.add(match[1]); + } + + return classes; +} + +/** + * Résout un import CSS module, y compris l'alias Vite/TS `@/` → `src/`. + */ +function resolveCssImport(sourceFile, cssImport) { + if (cssImport.startsWith('@/')) { + return path.resolve(SRC_DIR, cssImport.slice(2)); + } + + return path.resolve(path.dirname(sourceFile), cssImport); +} + +/** + * Supprime commentaires // et /* *\/ sans trop casser les URL http://. + */ +function removeJsComments(content) { + return content + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/(^|[^:\/])\/\/.*$/gm, '$1'); +} + +/** + * Extrait les imports CSS modules. + * + * Ex: + * import styles from './Button.module.css' + * import classes from "./foo.module.css"; + * import screen from '@/shared/styles/screen.module.css' + */ +function extractModuleImports(content) { + const imports = []; + + const regex = + /import\s+([A-Za-z_$][\w$]*)\s+from\s+['"]([^'"]+\.module\.css)['"]/g; + + let match; + + while ((match = regex.exec(content)) !== null) { + imports.push({ + variable: match[1], + file: match[2], + }); + } + + return imports; +} + +/** + * Escape pour utilisation dans une RegExp. + */ +function escapeRegex(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Littéraux string des alias de types (`type Foo = 'a' | 'b'`). + * Sert à résoudre styles[color] / styles[size] dans le même fichier. + */ +function extractTypeStringLiterals(content) { + const literals = new Set(); + const typeAlias = + /(?:export\s+)?type\s+[A-Za-z_$][\w$]*\s*=\s*([^;{]+)/g; + + let match; + + while ((match = typeAlias.exec(content)) !== null) { + const litRegex = /['"]([^'"]+)['"]/g; + let litMatch; + + while ((litMatch = litRegex.exec(match[1])) !== null) { + const value = litMatch[1]; + + if (/^-?[_a-zA-Z][_a-zA-Z0-9-]*$/.test(value)) { + literals.add(value); + } + } + } + + return literals; +} + +/** + * Classes visées par un accès dynamique styles[ident], via unions TS + * et valeurs par défaut (`size = 'md'`). + */ +function extractDynamicClasses(content, variable) { + const classes = new Set(); + const escaped = escapeRegex(variable); + const dynamicRegex = new RegExp( + `(? 0) { + for (const item of unresolvedImports) { + console.log( + `❌ CSS MODULE INTROUVABLE\n` + + ` ${relative(item.sourceFile)}\n` + + ` import ${item.variable} from '${item.file}'\n` + ); + } +} + +for (const module of [...cssModules.values()].sort((a, b) => + a.file.localeCompare(b.file) +)) { + const unused = [...module.defined] + .filter(className => !module.used.has(className)) + .sort(); + + const missing = [...module.missing.keys()].sort(); + const globalStrings = [...module.globalStrings.keys()].sort(); + + if ( + unused.length === 0 && + missing.length === 0 && + globalStrings.length === 0 && + module.importedBy.size > 0 + ) { + continue; + } + + console.log(`\n📄 ${relative(module.file)}`); + + /* + * Module jamais importé + */ + if (module.importedBy.size === 0) { + console.log(' ⚠️ Module CSS jamais importé'); + unimportedCount++; + } + + /* + * Classe du module écrite en string globale (className="foo"). + */ + if (globalStrings.length > 0) { + console.log( + '\n ⚠️ Classes du module utilisées en string globale :\n' + + ' (className="foo" n’applique pas le hash CSS module,' + + ' utiliser styles.foo ou styles[\'foo\'])' + ); + + for (const className of globalStrings) { + console.log(` .${className}`); + + for (const sourceFile of module.globalStrings.get(className)) { + console.log(` ↳ ${relative(sourceFile)}`); + } + + globalStringCount++; + } + } + + /* + * Classes CSS inutilisées + */ + if (unused.length > 0) { + console.log('\n 🟡 Classes définies mais non utilisées :'); + + for (const className of unused) { + console.log(` .${className}`); + unusedCount++; + } + } + + /* + * Classes JS absentes du CSS + */ + if (missing.length > 0) { + console.log('\n 🔴 Classes utilisées mais absentes du CSS :'); + + for (const className of missing) { + console.log(` ${className}`); + + const sourceFiles = module.missing.get(className); + + for (const sourceFile of sourceFiles) { + console.log( + ` ↳ ${relative(sourceFile)}` + ); + } + + missingCount++; + } + } +} + +console.log('\n=========================================='); +console.log(' Résumé'); +console.log('=========================================='); + +console.log(`🟡 Classes CSS inutilisées : ${unusedCount}`); +console.log(`🔴 Classes CSS absentes : ${missingCount}`); +console.log(`⚠️ Modules non importés : ${unimportedCount}`); +console.log(`⚠️ Strings globales : ${globalStringCount}`); +console.log(`❌ Modules introuvables : ${unresolvedImports.length}`); + +if ( + unusedCount === 0 && + missingCount === 0 && + unimportedCount === 0 && + globalStringCount === 0 && + unresolvedImports.length === 0 +) { + console.log('\n✅ Aucun problème détecté.'); +} else { + console.log(''); +} + +/* + * Code retour != 0 uniquement s'il existe + * des références vers des classes / fichiers inexistants. + * + * Pratique pour CI / npm test. + */ +process.exitCode = + missingCount > 0 || unresolvedImports.length > 0 ? 1 : 0; diff --git a/src/app/components/BottomTabbar/BottomTabbar.module.css b/src/app/components/BottomTabbar/BottomTabbar.module.css index 2a7d8a6..32f4583 100644 --- a/src/app/components/BottomTabbar/BottomTabbar.module.css +++ b/src/app/components/BottomTabbar/BottomTabbar.module.css @@ -60,11 +60,3 @@ .active .tabLabel { display: block; } - -.iconCircle { - display: none; -} - -.iconCircleActive { - display: none; -} diff --git a/src/app/components/LeftMenu/LeftMenu.module.css b/src/app/components/LeftMenu/LeftMenu.module.css index f7bc3d7..e17a210 100644 --- a/src/app/components/LeftMenu/LeftMenu.module.css +++ b/src/app/components/LeftMenu/LeftMenu.module.css @@ -165,41 +165,6 @@ fill: var(--color-text); } -.chevronExpanded { - transform: rotate(90deg); -} - -.groupItems { - max-height: 0; - overflow: hidden; - transition: max-height var(--duration-slow) var(--ease-standard); -} - -.groupItemsExpanded { - max-height: 18.75rem; -} - -.menuItem { - display: flex; - align-items: center; - width: 100%; - padding: var(--spacing-sm) var(--spacing-md) var(--spacing-sm) var(--spacing-2xl); - background: none; - border: none; - cursor: pointer; - text-align: left; - font-size: var(--font-size-base); - color: var(--color-border-strong); -} - -.menuItem:hover { - background-color: var(--color-bg-subtle); -} - -.menuItem:active { - background-color: var(--color-bg-active); -} - .standaloneItems { margin-top: auto; padding: var(--spacing-md); diff --git a/src/features/auth/pages/AuthCallback/AuthCallbackPage.module.css b/src/features/auth/pages/AuthCallback/AuthCallbackPage.module.css index 3275faa..c54d868 100644 --- a/src/features/auth/pages/AuthCallback/AuthCallbackPage.module.css +++ b/src/features/auth/pages/AuthCallback/AuthCallbackPage.module.css @@ -9,6 +9,6 @@ margin-top: 1.5rem; } -.text-error { +.textError { color: var(--color-danger); } \ No newline at end of file diff --git a/src/features/auth/pages/AuthCallback/AuthCallbackPage.tsx b/src/features/auth/pages/AuthCallback/AuthCallbackPage.tsx index 4af37c9..8ac8edb 100644 --- a/src/features/auth/pages/AuthCallback/AuthCallbackPage.tsx +++ b/src/features/auth/pages/AuthCallback/AuthCallbackPage.tsx @@ -67,7 +67,7 @@ export function AuthCallbackPage() { return (
{error}
+{error}
diff --git a/src/features/auth/pages/MyAccount/MyAccountPage.module.css b/src/features/auth/pages/MyAccount/MyAccountPage.module.css index e5a4580..058833a 100644 --- a/src/features/auth/pages/MyAccount/MyAccountPage.module.css +++ b/src/features/auth/pages/MyAccount/MyAccountPage.module.css @@ -64,6 +64,10 @@ fill: var(--icon-color-8); } +.photoIcon { + width: var(--icon-base); + height: var(--icon-base); +} .cameraButton { position: absolute; bottom: 0; @@ -157,18 +161,6 @@ box-shadow: var(--shadow-sm); } -.contributionIcon { - width: 2.5rem; - height: 2.5rem; - color: var(--color-text-secondary); - background-color: var(--color-bg-primary-subtle); - border-radius: 1.5rem; -} - -.contributionIcon path { - fill: var(--color-text-primary-8); -} - .contributionValue { font-size: var(--font-size-2xl); font-weight: var(--font-weight-bold); @@ -183,19 +175,6 @@ font-weight: var(--font-weight-normal); } -.badge { - position: absolute; - top: var(--spacing-xs); - right: var(--spacing-xs); - width: 1.5rem; - height: 1.5rem; - border-radius: var(--radius-circle); - background-color: var(--color-action-primary); - display: flex; - align-items: center; - justify-content: center; - color: var(--color-bg-surface); -} /* ── Note de bas de page ── */ .footerNote { diff --git a/src/features/legend/pages/LegendPage/LegendPage.module.css b/src/features/legend/pages/LegendPage/LegendPage.module.css index d8d7422..22661ef 100644 --- a/src/features/legend/pages/LegendPage/LegendPage.module.css +++ b/src/features/legend/pages/LegendPage/LegendPage.module.css @@ -1,9 +1,6 @@ -.placeholder { - margin: 1.5rem 0 0; - padding: 1.25rem; - border-radius: 0.75rem; - background: var(--color-bg-surface); - color: var(--color-text-secondary); - font-size: 0.9375rem; - text-align: center; -} + +.content { + display: flex; + flex-direction: column; + gap: 16px; +} \ No newline at end of file diff --git a/src/features/legend/pages/LegendPage/LegendPage.tsx b/src/features/legend/pages/LegendPage/LegendPage.tsx index 3b12c4d..d535d2f 100644 --- a/src/features/legend/pages/LegendPage/LegendPage.tsx +++ b/src/features/legend/pages/LegendPage/LegendPage.tsx @@ -1,5 +1,7 @@ import { MapOverlaySheet } from '@/features/map/components/MapOverlaySheet'; +import styles from './LegendPage.module.css'; + export interface LegendPageProps { isOpen: boolean; onClose: () => void; @@ -8,10 +10,12 @@ export interface LegendPageProps { export function LegendPage({ isOpen, onClose }: LegendPageProps) { return (TODO — Écran pas encore développé
-- Symboles des repères, clusters et stations affichés sur la carte. -
+TODO — Écran pas encore développé
++ Symboles des repères, clusters et stations affichés sur la carte. +
+{description}
{legend ? (