diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5b056a..3e23d86 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,13 +1,29 @@ name: CI +# Uniquement sur les PR vers main (pas de `push: main` en doublon : le contenu mergé en rebase a +# déjà été validé sur la PR). Les PR qui ne touchent que la doc/les images ne lancent rien. on: - push: - branches: [main] pull_request: + branches: [main] + types: [opened, synchronize, reopened, ready_for_review] + paths-ignore: + - '**.md' + - 'docs/**' + - '**.png' + - '**.jpg' + - '**.svg' + - 'LICENSE' + +# Un nouveau push sur la PR annule l'exécution précédente, devenue obsolète. +concurrency: + group: ci-${{ github.event.pull_request.number }} + cancel-in-progress: true jobs: lint-and-build: + if: github.event.pull_request.draft == false runs-on: ubuntu-latest + timeout-minutes: 5 steps: - uses: actions/checkout@v4 @@ -23,6 +39,11 @@ jobs: # tsc -b type-check aussi tests/e2e/ (cf. tsconfig.tests.json), sans avoir besoin de # lancer Playwright ici : tester une extension Chrome en CI demande un Chromium headed - # (xvfb) et un profil persistant, pas encore mis en place — voir UPGRADES.md. + # (xvfb) et un profil persistant, pas encore mis en place. - name: Build run: npm run build + + # Tests unitaires Vitest de src/core/ (< 1 s d'exécution, même job pour ne pas refaire + # checkout + install). + - name: Unit tests + run: npm test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fe105ba..688d280 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,6 +14,7 @@ permissions: jobs: release: runs-on: ubuntu-latest + timeout-minutes: 5 steps: - uses: actions/checkout@v4 with: diff --git a/.gitignore b/.gitignore index ace01c5..c62b054 100644 --- a/.gitignore +++ b/.gitignore @@ -13,8 +13,14 @@ dist-test dist-ssr *.local -# Notes de travail locales, jamais partagées (préférence utilisateur, tous projets) -UPGRADES.md +# Dataset de classes régénéré avant chaque dev/build/test (npm run generate:tw-data) +/src/data/generated/ + +# Notes de travail locales, jamais partagées (préférence utilisateur, tous projets) : tous les +# .md sauf README et CHANGELOG +*.md +!README.md +!CHANGELOG.md # Playwright (tests/e2e) /test-results/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..32fd3a7 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +git-tag-version=false diff --git a/README.md b/README.md index 9573734..7a14aed 100644 --- a/README.md +++ b/README.md @@ -14,17 +14,20 @@ Cible **Tailwind CSS v4** (thème par défaut en OKLCH, variables CSS `@theme`, ## Fonctionnalités -- **Picker visuel** : clic sur l'icône ou `Ctrl+Shift+K` pour activer le picker, clic sur un élément de la page pour sélectionner ce qu'on veut éditer. Fil d'ariane des ancêtres, navigation clavier (flèches), mode verrouillé pour interagir avec la page sans perdre la sélection (bouton 🔒 ou `Échap` directement sur la page). -- **Panneau dans une fenêtre séparée**, déplaçable indépendamment (utile sur un second écran) : classes regroupées par catégorie (couleurs, spacing, typographie, bordures, effets, filtres, transitions, interactivité...) dans un rail redimensionnable, recherche transversale, valeurs récentes, export en texte brut ou JSX. -- **Historique de session** : chaque ajout/retrait de classe, sur n'importe quel élément de la page (pas juste la sélection courante), est loggué avec un diff `+classe`/`−classe` — pratique pour retrouver l'ensemble des modifications faites à différents endroits avant de finaliser. Copiable, vidable. +- **Picker visuel** : clic sur l'icône ou `Ctrl+Shift+K` pour activer le picker, clic sur un élément de la page pour sélectionner ce qu'on veut éditer. Surbrillance façon DevTools (dimensions, marge et padding, survol en pointillé / sélection en trait plein), fil d'ariane des ancêtres, navigation clavier (flèches), mode verrouillé pour interagir avec la page sans perdre la sélection (bouton 🔒 ou `Échap` directement sur la page). +- **Panneau dans une fenêtre séparée**, déplaçable indépendamment (utile sur un second écran) : classes regroupées par catégorie (couleurs, spacing, typographie, bordures, effets, filtres, transitions, interactivité...) dans un rail redimensionnable, valeurs récentes, export en texte brut ou JSX. +- **Aperçu au survol** : survoler une valeur dans la liste d'une propriété l'applique temporairement sur la page, sans rien enregistrer ; le clic la valide. +- **Recherche transversale** : résultats classés (exact, puis début de nom…), recherche par valeur CSS (`16px` → `p-4`, `text-base`…), suggestions tolérantes aux fautes de frappe. +- **Variants** : breakpoints réels du site (lus dans ses media queries), pseudo-classes courantes en accès direct (`hover`, `focus-visible`, `group-hover`, `first`…), variant libre, réinitialisation en un clic. +- **Historique de session et annuler/rétablir** : chaque ajout/retrait de classe, sur n'importe quel élément de la page (pas juste la sélection courante), est loggué avec un diff `+classe`/`−classe`. `Ctrl+Z` / `Ctrl+Shift+Z` (ou les boutons ↶ ↷), annulation d'une entrée précise depuis l'historique. Export des modifications ou des classes finales de tous les éléments modifiés, chacun repéré par un sélecteur CSS unique. - **Synthèse CSS live** : une classe choisie dans le panneau produit un effet visuel immédiat même si elle est absente du CSS déjà chargé sur la page (build de prod purgé) — variants `hover:`, `dark:`, breakpoints, `group-*`/`peer-*`, `aria-*`, `has-*`, `data-*`, opacité de couleur (`bg-red-500/80`), propriétés composites (transform/filter/backdrop-filter) synthétisées fidèlement au vrai moteur v4. - **Détection du thème réel du site** : les classes synthétisées référencent les vraies variables CSS `@theme` du site (avec repli sur notre thème par défaut), y compris si le site utilise un préfixe Tailwind custom (`tw:bg-red-500` → `--tw-color-red-500`). - **Navigateur de variables de thème** : liste les `--color-*`/`--radius-*`/`--spacing`/etc. réellement définis sur `:root` du site. - **Scan CSS** : détecte les classes custom (non-Tailwind) utilisées sur la page en parsant les feuilles de style chargées (avec repli `fetch()` pour le cross-origin autorisant CORS), re-scanne automatiquement si le site charge du CSS dynamiquement. - **Contrôle de contraste WCAG** : ratio texte/fond de l'élément sélectionné (AA/AAA), aperçu du contraste par couleur candidate avant de l'appliquer. -- Thème clair/sombre du panneau, libellés de catégorie en français ou anglais (toggle FR/EN, pas de mélange), raccourcis clavier, indicateur de classe non synthétisable. +- Interface en français ou en anglais (langue du navigateur par défaut, toggle FR/EN), thème clair/sombre du panneau, raccourcis clavier, indicateur de classe non synthétisable, reconnexion en un clic après rechargement de la page quand Chrome le permet. -Détail complet des fonctionnalités et idées futures : [UPGRADES.md](UPGRADES.md). +Bugs et idées d'amélioration : [issues GitHub](../../issues). ## Installation @@ -50,21 +53,28 @@ Puis charger le dossier `dist/` de la même façon. ```sh npm run dev # build en mode watch (HMR pour le panneau) npm run lint # oxlint +npm test # tests unitaires (Vitest) npm run build # build de production dans dist/ ``` -Le dataset de classes (`src/data/generated/`) est régénéré automatiquement avant chaque build (`npm run generate:tw-data`) à partir du thème par défaut Tailwind v4 croisé avec la taxonomie éditée à la main (`src/data/taxonomy.ts`) — jamais de classe tapée en dur. +Le dataset de classes (`src/data/generated/`, non versionné) est régénéré automatiquement avant chaque `dev`, `build` et `test` (`npm run generate:tw-data`) à partir du thème par défaut Tailwind v4 croisé avec la taxonomie éditée à la main (`src/data/taxonomy/`) — jamais de classe tapée en dur. ### Structure - `src/core/` — logique indépendante du DOM/React : parsing de classes, diff, synthèse CSS live, scan CSS, contraste WCAG. -- `src/content/` — content script injecté à la demande sur la page éditée. +- `src/content/` — content script injecté à la demande sur la page éditée. Volontairement léger (~13 Ko) : il n'embarque ni la taxonomie ni le dataset ; le panneau calcule le diff de classes et le CSS de prévisualisation, la page se contente de les appliquer. - `src/devpanel/` — l'interface React du panneau (fenêtre séparée). - `src/background/` — service worker (activation, raccourcis). -- `src/data/taxonomy.ts` — la seule table éditée à la main ; `scripts/generate-tailwind-data.ts` en dérive le dataset complet des classes. +- `src/data/taxonomy/` — la seule table éditée à la main (un fichier par catégorie) ; `scripts/generate-tailwind-data.ts` en dérive le dataset complet des classes. ### Tests +Tests unitaires Vitest de `src/core/` (parsing, diff, synthèse CSS, variants, contraste, validation des messages) : + +```sh +npm test +``` + Suite e2e Playwright, extension chargée dans un vrai Chromium : ```sh @@ -77,15 +87,18 @@ Le build de test (`npm run build:test`) diffère du build de production sur deux ### Publier une release -Une seule commande (working directory propre requis) : +Rien n'est poussé directement sur `main` : la release passe par une PR, puis le tag est posé après intégration (le rebase réécrit les SHA). ```sh -npm version patch # ou minor / major +npm version patch # ou minor / major — depuis main propre et à jour +# … intégrer la PR ouverte (rebase), puis : +npm run release:tag ``` -`npm version` bump `package.json`, commit et tague (`vX.Y.Z`) en un coup ; les hooks `preversion`/`postversion` (voir `package.json`) font le reste automatiquement : -1. `preversion` — lint + type-check, annule tout si ça échoue (rien n'est bumpé/tagué). -2. `postversion` — pousse le commit ET le tag, ce qui déclenche `.github/workflows/release.yml` (build, zip, Release GitHub). +1. `preversion` — vérifie qu'on est sur `main` propre et aligné sur `origin/main`, puis lint + type-check ; annule tout si ça échoue (rien n'est bumpé). +2. `npm version` bump `package.json`/`package-lock.json` sans commit ni tag (`.npmrc` : `git-tag-version=false`). +3. `postversion` — crée la branche `release/vX.Y.Z`, commit `chore: release vX.Y.Z`, pousse la branche et ouvre la PR. +4. `npm run release:tag` — met `main` à jour, tague le commit de release (`vX.Y.Z`) et pousse le tag, ce qui déclenche `.github/workflows/release.yml`. Le workflow `.github/workflows/release.yml` build, zippe `dist/` et publie automatiquement une Release GitHub avec le zip en pièce jointe. diff --git a/manifest.config.ts b/manifest.config.ts index e2f9771..ef7f7ce 100644 --- a/manifest.config.ts +++ b/manifest.config.ts @@ -3,9 +3,13 @@ import pkg from './package.json' with { type: 'json' } export default defineManifest(({ mode }) => ({ manifest_version: 3, - name: 'DevWind', + // Textes du manifest traduits par Chrome selon la langue du navigateur (public/_locales/, + // copié tel quel à la racine de dist/) ; l'interface du panneau a sa propre traduction, + // commutable à chaud (src/devpanel/i18n/). + default_locale: 'en', + name: '__MSG_extName__', version: pkg.version, - description: 'Éditeur visuel de classes Tailwind CSS, en direct dans le navigateur.', + description: '__MSG_extDescription__', // Fichiers dans public/icons/ : Vite copie déjà tout public/ tel quel à la racine de dist/ // (public/icons/icon16.png -> dist/icons/icon16.png), donc les chemins ci-dessous sont // relatifs à dist/, pas au projet. @@ -33,7 +37,7 @@ export default defineManifest(({ mode }) => ({ commands: { 'toggle-picker': { suggested_key: { default: 'Ctrl+Shift+K', mac: 'Command+Shift+K' }, - description: 'Activer/désactiver le picker DevWind', + description: '__MSG_commandToggle__', }, }, permissions: ['activeTab', 'scripting', 'storage'], diff --git a/package-lock.json b/package-lock.json index 7b79ee0..0270c3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,11 +21,13 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", + "happy-dom": "^20.14.5", "oxlint": "^1.71.0", "tailwindcss": "^4.3.3", "tsx": "^4.23.1", "typescript": "~6.0.2", - "vite": "^8.1.1" + "vite": "^8.1.1", + "vitest": "^5.0.3" } }, "node_modules/@crxjs/vite-plugin": { @@ -530,13 +532,34 @@ "node": ">=18" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -1222,6 +1245,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/chrome": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.2.2.tgz", @@ -1233,6 +1267,20 @@ "@types/har-format": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/filesystem": { "version": "0.0.36", "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz", @@ -1287,6 +1335,23 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz", @@ -1313,6 +1378,54 @@ } } }, + "node_modules/@vitest/mocker": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", + "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.3", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", + "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@webcomponents/custom-elements": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@webcomponents/custom-elements/-/custom-elements-1.6.0.tgz", @@ -1346,6 +1459,16 @@ "node": ">=0.4.0" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -1353,6 +1476,29 @@ "dev": true, "license": "ISC" }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1546,6 +1692,26 @@ "@esbuild/win32-x64": "0.28.1" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1601,6 +1767,38 @@ "dev": true, "license": "ISC" }, + "node_modules/happy-dom": { + "version": "20.14.5", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.14.5.tgz", + "integrity": "sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/happy-dom/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -1970,6 +2168,20 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/oxlint": { "version": "1.75.0", "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.75.0.tgz", @@ -2034,9 +2246,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -2219,6 +2431,13 @@ "node": ">=0.10.0" } }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", @@ -2226,6 +2445,26 @@ "dev": true, "license": "MIT" }, + "node_modules/tinybench": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.1.tgz", + "integrity": "sha512-IAbQaPJIIdhVxi0mqy9lez1wEwje0UQf9F1vwtbelEG5Nbgn0nPOxCvU+mNeNS9gyJTAhrHp74CbUaSynQpZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2378,6 +2617,151 @@ } } }, + "node_modules/vitest": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", + "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.3", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.3", + "@vitest/browser-preview": "5.0.3", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.3", + "@vitest/coverage-v8": "5.0.3", + "@vitest/ui": "5.0.3", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/zustand": { "version": "5.0.14", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", diff --git a/package.json b/package.json index 51b0f21..9e56ba4 100644 --- a/package.json +++ b/package.json @@ -25,8 +25,11 @@ "preview": "vite preview", "pretest:e2e": "npm run build:test", "test:e2e": "playwright test", - "preversion": "npm run lint && tsc -b", - "postversion": "git push && git push --tags" + "pretest": "npm run generate:tw-data", + "test": "vitest run", + "preversion": "tsx scripts/release.ts check && npm run lint && npm test && tsc -b", + "postversion": "tsx scripts/release.ts open", + "release:tag": "tsx scripts/release.ts tag" }, "dependencies": { "react": "^19.2.7", @@ -41,10 +44,12 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", + "happy-dom": "^20.14.5", "oxlint": "^1.71.0", "tailwindcss": "^4.3.3", "tsx": "^4.23.1", "typescript": "~6.0.2", - "vite": "^8.1.1" + "vite": "^8.1.1", + "vitest": "^5.0.3" } } diff --git a/public/_locales/en/messages.json b/public/_locales/en/messages.json new file mode 100644 index 0000000..3641835 --- /dev/null +++ b/public/_locales/en/messages.json @@ -0,0 +1,14 @@ +{ + "extName": { + "message": "DevWind", + "description": "Extension name." + }, + "extDescription": { + "message": "Visual Tailwind CSS class editor, live in the browser.", + "description": "Extension description (Chrome and the Chrome Web Store)." + }, + "commandToggle": { + "message": "Open/close the DevWind panel", + "description": "Keyboard shortcut description." + } +} diff --git a/public/_locales/fr/messages.json b/public/_locales/fr/messages.json new file mode 100644 index 0000000..4f4b197 --- /dev/null +++ b/public/_locales/fr/messages.json @@ -0,0 +1,14 @@ +{ + "extName": { + "message": "DevWind", + "description": "Nom de l'extension." + }, + "extDescription": { + "message": "Éditeur visuel de classes Tailwind CSS, en direct dans le navigateur.", + "description": "Description de l'extension (Chrome et Chrome Web Store)." + }, + "commandToggle": { + "message": "Ouvrir/fermer le panneau DevWind", + "description": "Description du raccourci clavier." + } +} diff --git a/scripts/generate-tailwind-data.ts b/scripts/generate-tailwind-data.ts index 36a4c09..8e2d1b4 100644 --- a/scripts/generate-tailwind-data.ts +++ b/scripts/generate-tailwind-data.ts @@ -19,7 +19,7 @@ import { fileURLToPath } from 'node:url' // sont bien présents à côté du .mjs ; le typage manuel ci-dessous suffit de toute façon. import rawDefaultTheme from 'tailwindcss/defaultTheme' import { taxonomy } from '../src/data/taxonomy' -import type { GeneratedClass, SlimGeneratedClass, TaxonomyEntry } from '../src/types' +import type { GeneratedClass, TaxonomyEntry } from '../src/types' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -158,22 +158,10 @@ const outDir = path.resolve(__dirname, '../src/data/generated') fs.mkdirSync(outDir, { recursive: true }) const outFile = path.join(outDir, 'tailwind-classes.json') -fs.writeFileSync(outFile, JSON.stringify(generated, null, 2)) - -// Version allégée pour le content script (class-parser.ts, live-style.ts) : `category`/ -// `subcategory` ne servent qu'à l'affichage/regroupement dans le devpanel (src/devpanel/data.ts, -// PropertyRow.tsx...), jamais à la reconnaissance ou à la synthèse de classes — inutile de les -// embarquer dans le bundle injecté sur chaque page (~22% de réduction sur ce fichier). -const slim: SlimGeneratedClass[] = generated.map(({ className, taxonomyId, prefix, themeKey, themeToken, secondaryValue, negative }) => ({ - className, - taxonomyId, - prefix, - themeKey, - themeToken, - secondaryValue, - negative, -})) -const slimOutFile = path.join(outDir, 'tailwind-classes-slim.json') -fs.writeFileSync(slimOutFile, JSON.stringify(slim)) - -console.log(`[generate-tailwind-data] ${generated.length} classes générées -> ${path.relative(process.cwd(), outFile)} (+ version allégée pour le content script)`) +fs.writeFileSync(outFile, JSON.stringify(generated)) + +// Ancienne version allégée destinée au content script : celui-ci n'embarque plus le dataset +// (diff et CSS calculés par le devpanel), on retire le fichier s'il reste d'un build précédent. +fs.rmSync(path.join(outDir, 'tailwind-classes-slim.json'), { force: true }) + +console.log(`[generate-tailwind-data] ${generated.length} classes générées -> ${path.relative(process.cwd(), outFile)}`) diff --git a/scripts/release.ts b/scripts/release.ts new file mode 100644 index 0000000..78a3666 --- /dev/null +++ b/scripts/release.ts @@ -0,0 +1,68 @@ +// Release en deux temps, sans jamais pousser sur main : tout passe par une PR intégrée en +// rebase, qui réécrit les SHA → le tag ne peut être posé qu'une fois la PR intégrée. +// 1. `npm version patch|minor|major` : `.npmrc` (git-tag-version=false) limite npm au bump de +// package.json/package-lock.json ; `preversion` lance `check`, `postversion` lance `open` +// (branche release/vX.Y.Z + commit + push de la branche + PR). +// 2. Après intégration de la PR : `npm run release:tag` pose le tag vX.Y.Z sur le commit de +// release de main et le pousse, ce qui déclenche .github/workflows/release.yml. +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' + +const run = (cmd: string, args: string[]) => { + execFileSync(cmd, args, { stdio: 'inherit' }) +} +const read = (cmd: string, args: string[]) => execFileSync(cmd, args, { encoding: 'utf8' }).trim() + +function fail(message: string): never { + console.error(`release : ${message}`) + process.exit(1) +} + +function currentVersion(): string { + return JSON.parse(fs.readFileSync('package.json', 'utf8')).version +} + +function ensureSyncedMain() { + if (read('git', ['branch', '--show-current']) !== 'main') fail('à lancer depuis main') + if (read('git', ['status', '--porcelain'])) fail('working directory non propre') + run('git', ['fetch', 'origin', 'main']) + if (read('git', ['rev-parse', 'HEAD']) !== read('git', ['rev-parse', 'origin/main'])) { + fail('main local différent de origin/main (git pull --rebase)') + } +} + +function openReleasePr() { + const tag = `v${currentVersion()}` + const branch = `release/${tag}` + run('git', ['switch', '-c', branch]) + run('git', ['add', 'package.json', 'package-lock.json']) + run('git', ['commit', '-m', `chore: release ${tag}`]) + run('git', ['push', '-u', 'origin', branch]) + run('gh', [ + 'pr', 'create', '--base', 'main', '--head', branch, + '--title', `chore: release ${tag}`, + '--body', `Release ${tag}. Après intégration (rebase) : \`npm run release:tag\` pour poser et pousser le tag.`, + ]) + run('git', ['switch', 'main']) +} + +function tagRelease() { + run('git', ['switch', 'main']) + run('git', ['pull', '--rebase', 'origin', 'main']) + const tag = `v${currentVersion()}` + if (read('git', ['tag', '--list', tag])) fail(`le tag ${tag} existe déjà`) + // Le commit de release n'est pas forcément HEAD si d'autres PR ont été intégrées depuis. + const sha = read('git', ['log', '-1', '--format=%H', '--fixed-strings', `--grep=chore: release ${tag}`]) + if (!sha) fail(`commit "chore: release ${tag}" introuvable sur main (PR pas encore intégrée ?)`) + run('git', ['tag', '-a', tag, sha, '-m', tag]) + run('git', ['push', 'origin', tag]) +} + +const commands: Record void> = { + check: ensureSyncedMain, + open: openReleasePr, + tag: tagRelease, +} +const command = commands[process.argv[2] ?? ''] +if (!command) fail('usage : tsx scripts/release.ts check|open|tag') +command() diff --git a/src/background/service-worker.ts b/src/background/service-worker.ts index 1c35d2c..92b97a7 100644 --- a/src/background/service-worker.ts +++ b/src/background/service-worker.ts @@ -1,14 +1,16 @@ -import { toggleDevPanel } from '../core/activation' +import { closeDevPanelOfRemovedTab, toggleDevPanel } from '../core/activation' -chrome.runtime.onInstalled.addListener(() => { - console.log('[DevWind] extension installée') -}) - -// Clic sur l'icône : ouvre/ferme directement la fenêtre devpanel (plus de popup intermédiaire). +// Clic sur l'icône : ouvre/ferme directement la fenêtre devpanel. chrome.action.onClicked.addListener((tab) => { if (tab.id != null) void toggleDevPanel(tab.id) }) +// Onglet de la page fermé : ferme aussi sa fenêtre devpanel (sinon elle resterait ouverte, +// déconnectée, sans rien à éditer). +chrome.tabs.onRemoved.addListener((tabId) => { + void closeDevPanelOfRemovedTab(tabId) +}) + // Raccourci clavier (Ctrl+Shift+K) : même bascule, geste utilisateur qualifiant pour activeTab // au même titre qu'un clic sur l'icône. chrome.commands.onCommand.addListener((command, tab) => { diff --git a/src/content/change-log.ts b/src/content/change-log.ts new file mode 100644 index 0000000..a05ba84 --- /dev/null +++ b/src/content/change-log.ts @@ -0,0 +1,113 @@ +import { applyClassDiff } from '../core/class-diff' +import { getClassAttr, readClassList } from '../core/class-attr' +import { shortElementLabel, uniqueSelector } from '../core/unique-selector' +import { ensureLiveRule } from './live-injection' +import { MAX_CHANGE_LOG_ENTRIES } from '../types' +import type { ChangeLogEntry, ClassChangeResult, ElementClassesSnapshot, LiveRule, RevertRejection } from '../types' + +// Historique des modifications de TOUTE la page pendant la session (pas juste l'élément +// sélectionné) : permet de retrouver l'ensemble des changements faits à différents endroits +// sans avoir à s'en souvenir soi-même, et de les annuler. Vidé au rechargement de la page (le +// content script est ré-injecté à zéro), plafonné pour éviter une croissance illimitée. +let entries: ChangeLogEntry[] = [] +/** Élément touché par chaque entrée (pour annuler/rétablir et exporter les classes finales). */ +const entryElements = new Map() +/** Attribut `class` d'origine de chaque élément modifié (avant sa première modification). */ +const originalClasses = new WeakMap() +/** Compteur partagé par les ids d'entrée et les annulations : leur ordre relatif dit au panneau + * si une annulation est plus récente que la dernière modification (cf. `undoneSeq`). */ +let seq = 0 + +export function getChangeLog(): ChangeLogEntry[] { + return entries +} + +export function clearChangeLog(): void { + entries = [] + entryElements.clear() +} + +/** Diffe un `ClassChangeResult` et l'ajoute à l'historique ; `null` si la modification n'a en + * fait rien changé (ex. reposer la même valeur déjà active). */ +export function logChange(el: Element, result: ClassChangeResult): ChangeLogEntry | null { + const before = result.before.split(/\s+/).filter(Boolean) + const after = result.after.split(/\s+/).filter(Boolean) + const added = after.filter((c) => !before.includes(c)) + const removed = before.filter((c) => !after.includes(c)) + if (added.length === 0 && removed.length === 0) return null + + if (!originalClasses.has(el)) originalClasses.set(el, result.before) + const entry: ChangeLogEntry = { + id: ++seq, + timestamp: Date.now(), + elementLabel: shortElementLabel(el), + selector: uniqueSelector(el), + added, + removed, + } + entries.push(entry) + entryElements.set(entry.id, el) + if (entries.length > MAX_CHANGE_LOG_ENTRIES) { + const dropped = entries.slice(0, entries.length - MAX_CHANGE_LOG_ENTRIES) + entries = entries.slice(dropped.length) + for (const d of dropped) entryElements.delete(d.id) + } + return entry +} + +export type RevertOutcome = + | { ok: true; entry: ChangeLogEntry; el: Element } + | { ok: false; reason: RevertRejection } + | null + +/** + * Annule (`undo`) ou rétablit une entrée, par diff inverse plutôt qu'en restaurant l'attribut + * entier : les modifications faites depuis sur d'autres classes du même élément sont gardées. + * Refusé si l'élément a quitté la page, ou si les classes que l'on retirerait ne sont plus là + * (modifiées depuis : appliquer le diff mélangerait deux états). `null` : entrée inconnue ou + * déjà dans l'état demandé. + */ +export function revertEntry(id: number, undo: boolean, liveRules: Map): RevertOutcome { + const index = entries.findIndex((e) => e.id === id) + const el = entryElements.get(id) + if (index === -1 || !el) return null + const entry = entries[index] + if (undo === (entry.undoneSeq !== undefined)) return null + if (!el.isConnected) return { ok: false, reason: 'detached' } + + const toRemove = undo ? entry.added : entry.removed + const toAdd = undo ? entry.removed : entry.added + const current = readClassList(el) + if (!toRemove.every((c) => current.includes(c))) return { ok: false, reason: 'modified' } + + for (const cls of toAdd) ensureLiveRule(cls, liveRules.get(cls) ?? null) + applyClassDiff(el, toRemove, toAdd) + + const updated: ChangeLogEntry = { ...entry } + if (undo) updated.undoneSeq = ++seq + else delete updated.undoneSeq + entries[index] = updated + return { ok: true, entry: updated, el } +} + +/** Mêmes classes, dans n'importe quel ordre (une annulation rajoute les classes en fin de liste). */ +function sameClassSet(a: string, b: string): boolean { + const norm = (s: string) => s.split(/\s+/).filter(Boolean).sort().join(' ') + return norm(a) === norm(b) +} + +/** Classes actuelles de chaque élément modifié cette session, encore dans la page et différent + * de son état d'origine (une modification annulée ne compte plus), dans l'ordre de première + * modification. */ +export function finalClasses(): ElementClassesSnapshot[] { + const seen = new Set() + const out: ElementClassesSnapshot[] = [] + for (const entry of entries) { + const el = entryElements.get(entry.id) + if (!el || seen.has(el)) continue + seen.add(el) + if (!el.isConnected || sameClassSet(getClassAttr(el), originalClasses.get(el) ?? '')) continue + out.push({ selector: uniqueSelector(el), classes: readClassList(el) }) + } + return out +} diff --git a/src/content/element-colors.ts b/src/content/element-colors.ts new file mode 100644 index 0000000..737a981 --- /dev/null +++ b/src/content/element-colors.ts @@ -0,0 +1,35 @@ +import { colorAlpha } from '../core/color-alpha' +import type { ElementColors } from '../types' + +/** Effets qui modifient le rendu au-delà des simples couleurs de fond composées. */ +function hasNonColorEffects(style: CSSStyleDeclaration): boolean { + return style.backgroundImage !== 'none' || Number(style.opacity) < 1 || style.filter !== 'none' || style.mixBlendMode !== 'normal' +} + +/** Pile de fonds effective : fond propre de l'élément + fonds non transparents des ancêtres + * jusqu'au premier opaque (le devpanel les compose, puis sur blanc si toute la chaîne est + * translucide, comportement de rendu par défaut). Les dégradés/images, opacités, filtres et + * modes de fusion ne sont pas modélisés : simplement signalés via `approximate`. */ +export function computeEffectiveColors(el: Element): ElementColors { + const style = getComputedStyle(el) + const backdrop: string[] = [] + let approximate = hasNonColorEffects(style) + let opaque = colorAlpha(style.backgroundColor) >= 1 + let current = el.parentElement + while (current && !opaque) { + const ancestorStyle = getComputedStyle(current) + if (hasNonColorEffects(ancestorStyle)) approximate = true + const alpha = colorAlpha(ancestorStyle.backgroundColor) + if (alpha > 0) backdrop.push(ancestorStyle.backgroundColor) + opaque = alpha >= 1 + current = current.parentElement + } + return { + color: style.color, + backgroundColor: style.backgroundColor, + backdrop, + approximate, + fontSize: parseFloat(style.fontSize), + bold: Number(style.fontWeight) >= 700, + } +} diff --git a/src/content/live-injection.ts b/src/content/live-injection.ts new file mode 100644 index 0000000..70714fa --- /dev/null +++ b/src/content/live-injection.ts @@ -0,0 +1,91 @@ +import { collectClassesWithRules } from '../core/css-scanner' +import { normalizeSingleRule } from '../core/css-guard' +import type { LiveRule, LiveRuleStatus } from '../types' + +export const STYLE_ELEMENT_ID = 'devwind-live-styles' + +// Plafond sur le nombre de règles injectées : au-delà de MAX_INJECTED, on purge les plus +// anciennes jusqu'à PRUNE_TO — mais seulement celles dont la classe n'est plus portée par aucun +// élément de la page (purger une règle encore appliquée ferait disparaître son effet visuel). +// Suffisant pour éviter une croissance illimitée sur une session d'édition très longue, sans +// MutationObserver global. +const MAX_INJECTED = 300 +const PRUNE_TO = 250 +const injected = new Map() + +// Classes ayant une vraie règle dans le CSS du site : un seul parcours du CSSOM, réutilisé à +// chaque édition, puis invalidé quand les feuilles de style changent (cf. content/sync.ts). +let classesWithRules: Set | null = null + +export function invalidateRuleIndex(): void { + classesWithRules = null +} + +function hasRealRule(className: string): boolean { + classesWithRules ??= collectClassesWithRules(document, STYLE_ELEMENT_ID) + return classesWithRules.has(className) +} + +function getStyleEl(): HTMLStyleElement { + let el = document.getElementById(STYLE_ELEMENT_ID) as HTMLStyleElement | null + if (!el) { + el = document.createElement('style') + el.id = STYLE_ELEMENT_ID + document.head.appendChild(el) + } + return el +} + +function isClassInUse(className: string): boolean { + // getElementsByClassName prend un nom brut (pas de sélecteur) : aucun échappement nécessaire. + return document.getElementsByClassName(className).length > 0 +} + +/** `justAdded` : règle qu'on vient d'injecter, pas encore posée sur l'élément (ensureLiveRule + * est appelé avant applyClassEdit) — à ne surtout pas purger. */ +function pruneIfNeeded(justAdded: string) { + if (injected.size <= MAX_INJECTED) return + let toRemove = injected.size - PRUNE_TO + for (const [key, node] of injected) { + if (toRemove <= 0) break + if (key === justAdded || isClassInUse(key)) continue + node.remove() + injected.delete(key) + toRemove-- + } +} + +/** Les deux règles `dark:` synthétisées ont les mêmes déclarations et la même spécificité + * (`:where()` en a zéro) : à égalité, seul l'ordre d'apparition dans la feuille tranche. + * On émet en dernier la stratégie que le site utilise réellement en ce moment (`.dark` présent + * sur ``/`` = stratégie classe ; sinon media query), pour que notre règle gagne + * face à du vrai CSS du site qui ciblerait la même propriété avec une spécificité égale. */ +function orderedRules(liveRule: LiveRule): string[] { + if (!liveRule.dark) return liveRule.rules + const [mediaRule, classRule] = liveRule.rules + const hasDarkClass = document.documentElement.classList.contains('dark') || document.body?.classList.contains('dark') + return hasDarkClass ? [mediaRule, classRule] : [classRule, mediaRule] +} + +/** + * Garantit qu'une classe a un effet visuel même si le CSS de la page ne la définit pas : + * injecte le CSS synthétisé par le panneau (cf. core/live-style.ts) — sauf si une règle réelle + * existe déjà, toujours préférée. + */ +export function ensureLiveRule(fullClassName: string, liveRule: LiveRule | null): LiveRuleStatus { + if (injected.has(fullClassName)) return 'synthesized' + if (hasRealRule(fullClassName)) return 'has-real-rule' + if (!liveRule) return 'unsupported' + + // Chaque règle est parsée isolément par le navigateur et on injecte SA sérialisation : une + // règle qui ne passe pas (sélecteur `has-[…]` invalide...) rend la classe non supportée au + // lieu de casser ou de contaminer le reste de la feuille. + const normalized = orderedRules(liveRule).map(normalizeSingleRule) + if (normalized.some((r) => r == null)) return 'unsupported' + + const textNode = document.createTextNode(`${normalized.join('\n')}\n`) + getStyleEl().appendChild(textNode) + injected.set(fullClassName, textNode) + pruneIfNeeded(fullClassName) + return 'synthesized' +} diff --git a/src/content/main.tsx b/src/content/main.ts similarity index 87% rename from src/content/main.tsx rename to src/content/main.ts index 8b3230f..c08dfc2 100644 --- a/src/content/main.tsx +++ b/src/content/main.ts @@ -1,12 +1,13 @@ import { mountShadowHost } from './shadow-mount' import { createElementPicker } from './picker/elementPicker' import { notifyLockedFromPage, selectElement, setupSync } from './sync' +import { isPickerMessage } from '../core/message-guards' import type { PickerMessage, PickerState } from '../types' const HOST_ID = 'devwind-root-host' function mount() { - // Idempotent : si le popup ré-exécute le content script sur un onglet déjà monté + // Idempotent : si core/activation.ts ré-exécute le content script sur un onglet déjà monté // (double clic rapide, etc.), on ne remonte pas un second Shadow DOM. if (document.getElementById(HOST_ID)) return @@ -41,6 +42,7 @@ function mount() { pickerActive = false locked = false picker.stop() + picker.showSelection(null) }, onSelectionChanged: (el) => picker.showSelection(el), onSetLocked: (nextLocked) => { @@ -54,12 +56,13 @@ function mount() { }, }) - chrome.runtime.onMessage.addListener((message: PickerMessage, _sender, sendResponse) => { + chrome.runtime.onMessage.addListener((raw: unknown, sender, sendResponse) => { + if (sender.id !== chrome.runtime.id || !isPickerMessage(raw)) return false + const message: PickerMessage = raw const state = (): PickerState => ({ active: pickerActive }) switch (message.type) { case 'DEVWIND_PING': - case 'DEVWIND_GET_STATE': sendResponse(state()) return true case 'DEVWIND_SET_ACTIVE': { diff --git a/src/content/picker/box-highlight.ts b/src/content/picker/box-highlight.ts new file mode 100644 index 0000000..dde8979 --- /dev/null +++ b/src/content/picker/box-highlight.ts @@ -0,0 +1,114 @@ +// Une couche de surbrillance façon DevTools : marge (orange), padding (vert), cadre de la boîte +// et libellé avec ses dimensions. Implémentation impérative (le survol est un chemin chaud) et +// styles inline (pas de feuille à charger). Marge et padding sont dessinés par des bordures +// dont l'épaisseur vaut celle de la zone : aucun calcul de sous-rectangles. + +export type HighlightKind = 'hover' | 'selection' + +interface Palette { + frame: string + content: string + padding: string + margin: string + label: string +} + +// Survol en pointillé et zones plus claires, sélection en trait plein : on distingue d'un coup +// d'œil l'élément édité de celui qu'on s'apprête à cliquer. +const PALETTES: Record = { + hover: { + frame: '1px dashed #6366f1', + content: 'rgba(99, 102, 241, 0.10)', + padding: 'rgba(147, 196, 125, 0.30)', + margin: 'rgba(246, 178, 107, 0.30)', + label: '#4f46e5', + }, + selection: { + frame: '2px solid #6366f1', + content: 'rgba(99, 102, 241, 0.14)', + padding: 'rgba(147, 196, 125, 0.45)', + margin: 'rgba(246, 178, 107, 0.45)', + label: '#6366f1', + }, +} + +const BASE = ['position: fixed', 'pointer-events: none', 'z-index: 2147483647', 'box-sizing: border-box', 'display: none'] +const LABEL_GAP = 4 +const LABEL_HEIGHT = 20 + +export interface BoxHighlight { + show(el: Element, text: string): void + hide(): void +} + +function layer(container: ShadowRoot, extra: string[]): HTMLDivElement { + const div = document.createElement('div') + div.style.cssText = [...BASE, ...extra].join(';') + container.appendChild(div) + return div +} + +function px(value: string): number { + return Math.max(0, Number.parseFloat(value) || 0) +} + +function place(div: HTMLElement, top: number, left: number, width: number, height: number) { + div.style.display = 'block' + div.style.top = `${top}px` + div.style.left = `${left}px` + div.style.width = `${Math.max(0, width)}px` + div.style.height = `${Math.max(0, height)}px` +} + +function formatSize(n: number): string { + return String(Math.round(n * 10) / 10) +} + +export function createBoxHighlight(container: ShadowRoot, kind: HighlightKind): BoxHighlight { + const palette = PALETTES[kind] + const margin = layer(container, ['border-style: solid', `border-color: ${palette.margin}`]) + const padding = layer(container, ['border-style: solid', `border-color: ${palette.padding}`, `background: ${palette.content}`]) + const frame = layer(container, [`border: ${palette.frame}`]) + const label = layer(container, [ + `background: ${palette.label}`, + 'color: white', + 'font: 11px/1.4 system-ui, sans-serif', + 'padding: 2px 6px', + 'border-radius: 4px', + 'white-space: nowrap', + ]) + const parts = [margin, padding, frame, label] + + return { + show(el, text) { + const rect = el.getBoundingClientRect() + const cs = getComputedStyle(el) + const m = [px(cs.marginTop), px(cs.marginRight), px(cs.marginBottom), px(cs.marginLeft)] + const b = [px(cs.borderTopWidth), px(cs.borderRightWidth), px(cs.borderBottomWidth), px(cs.borderLeftWidth)] + const p = [px(cs.paddingTop), px(cs.paddingRight), px(cs.paddingBottom), px(cs.paddingLeft)] + + place(margin, rect.top - m[0], rect.left - m[3], rect.width + m[1] + m[3], rect.height + m[0] + m[2]) + margin.style.borderWidth = m.map((v) => `${v}px`).join(' ') + place(padding, rect.top + b[0], rect.left + b[3], rect.width - b[1] - b[3], rect.height - b[0] - b[2]) + padding.style.borderWidth = p.map((v) => `${v}px`).join(' ') + place(frame, rect.top, rect.left, rect.width, rect.height) + + label.textContent = `${text} · ${formatSize(rect.width)}×${formatSize(rect.height)}` + label.style.display = 'block' + // Au-dessus de l'élément s'il y a la place, sinon en dessous, sinon à l'intérieur ; jamais + // au-delà du bord droit de la fenêtre (élément collé à droite ou plus large que l'écran). + const top = + rect.top >= LABEL_HEIGHT + LABEL_GAP + ? rect.top - LABEL_HEIGHT - LABEL_GAP / 2 + : rect.bottom + LABEL_HEIGHT + LABEL_GAP <= window.innerHeight + ? rect.bottom + LABEL_GAP / 2 + : Math.max(rect.top, 0) + LABEL_GAP / 2 + const maxLeft = window.innerWidth - label.offsetWidth - LABEL_GAP + label.style.top = `${top}px` + label.style.left = `${Math.max(LABEL_GAP, Math.min(rect.left, maxLeft))}px` + }, + hide() { + for (const part of parts) part.style.display = 'none' + }, + } +} diff --git a/src/content/picker/elementPicker.ts b/src/content/picker/elementPicker.ts index 6d7ed76..680dc96 100644 --- a/src/content/picker/elementPicker.ts +++ b/src/content/picker/elementPicker.ts @@ -1,119 +1,150 @@ -import { createOverlay } from './overlay' +import { createBoxHighlight } from './box-highlight' +import { readClassList } from '../../core/class-attr' + +const PRESS_EVENTS = ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'dblclick'] as const export interface ElementPickerOptions { shadowRoot: ShadowRoot host: Element onSelect: (el: Element) => void /** Échap pendant le picking : laisse l'appelant décider de la réaction (verrouiller la - * sélection courante, cf. main.tsx) — le picker se contente de masquer son survol. */ + * sélection courante, cf. main.ts) — le picker se contente de masquer son survol. */ onEscape?: () => void } export interface ElementPicker { start(): void stop(): void - /** Affiche/masque le rectangle de surbrillance sur un élément précis, indépendamment du - * survol de la souris — utilisé pour la navigation clavier / le fil d'ariane des ancêtres, - * qui changent la sélection sans mouvement de souris (marche même après `stop()`, donc - * reste visible en mode verrouillé). */ + /** Affiche (ou masque, `null`) la surbrillance de la sélection, distincte de celle du survol + * et indépendante du picking : elle reste visible en mode verrouillé, suit le défilement et + * les changements de taille de l'élément (édition de classes, aperçu au survol). */ showSelection(el: Element | null): void } -function describeElement(el: Element): string { +/** Libellé court affiché sur la surbrillance (tag + première classe). À ne pas confondre avec + * `shortElementLabel` (historique), volontairement indépendant des classes. */ +function overlayLabel(el: Element): string { const tag = el.tagName.toLowerCase() - const classes = el.className ? el.className.toString().trim().split(/\s+/).filter(Boolean) : [] + const classes = readClassList(el) return classes.length ? `${tag}.${classes[0]}${classes.length > 1 ? ` +${classes.length - 1}` : ''}` : tag } /** * Picking continu (comme les DevTools "inspect element") : tant que le picker est actif, * chaque clic sur la page sélectionne l'élément visé (au lieu de laisser passer le clic - * normalement) et met à jour le panneau. Désactivé uniquement via le toggle du popup. + * normalement) et met à jour le panneau. Arrêté à la fermeture du panneau ou au verrouillage + * de la sélection (cf. main.ts). */ export function createElementPicker({ shadowRoot, host, onSelect, onEscape }: ElementPickerOptions): ElementPicker { - const overlay = createOverlay(shadowRoot) + const hoverBox = createBoxHighlight(shadowRoot, 'hover') + const selectionBox = createBoxHighlight(shadowRoot, 'selection') let active = false let rafId: number | null = null let lastHovered: Element | null = null + let selected: Element | null = null + const resizeObserver = new ResizeObserver(() => scheduleUpdate()) - function updateOverlayForLastHovered() { + function update() { rafId = null - if (lastHovered && lastHovered.isConnected) { - overlay.show(lastHovered.getBoundingClientRect(), describeElement(lastHovered)) - } else { - overlay.hide() - } + if (selected?.isConnected) selectionBox.show(selected, overlayLabel(selected)) + else selectionBox.hide() + // Survoler l'élément déjà sélectionné : sa surbrillance de sélection suffit. + if (active && lastHovered?.isConnected && lastHovered !== selected) hoverBox.show(lastHovered, overlayLabel(lastHovered)) + else hoverBox.hide() } - function scheduleOverlayUpdate() { + function scheduleUpdate() { if (rafId != null) return - rafId = requestAnimationFrame(updateOverlayForLastHovered) + rafId = requestAnimationFrame(update) } - // event.target est retargeté vers `host` par le navigateur quand l'événement provient - // de l'intérieur de notre shadow root (ouvert) : ce check suffit à ignorer les - // interactions avec notre propre UI, sans avoir besoin de elementFromPoint manuel. - function isOwnUi(target: EventTarget | null): boolean { - return !target || host.contains(target as Node) + // Élément réellement visé, y compris à l'intérieur d'un Shadow DOM de la page (web + // component) : `e.target` est retargeté vers l'hôte du shadow root, `composedPath()[0]` non. + // `null` si l'événement provient de notre propre UI (le chemin traverse alors `host`). + function pageTarget(e: Event): Element | null { + const path = e.composedPath() + if (path.includes(host)) return null + const first = path[0] + return first instanceof Element ? first : null } function onMouseMove(e: MouseEvent) { - const target = e.target as Element | null - if (isOwnUi(target)) { - lastHovered = null - overlay.hide() - return - } - lastHovered = target - scheduleOverlayUpdate() + lastHovered = pageTarget(e) + scheduleUpdate() } - function onClick(e: MouseEvent) { - const target = e.target as Element | null - if (isOwnUi(target)) return // clic dans notre propre UI : comportement normal + function swallow(e: Event) { e.preventDefault() e.stopPropagation() e.stopImmediatePropagation() - onSelect(target!) } - function onScrollOrResize() { - scheduleOverlayUpdate() + function onClick(e: MouseEvent) { + const target = pageTarget(e) + if (!target) return // clic dans notre propre UI : comportement normal + swallow(e) + onSelect(target) + } + + // Bloquer seulement `click` laissait passer les autres événements du geste (dropdowns qui + // s'ouvrent au mousedown, drag, liens en mousedown...) : on les avale aussi pendant le picking. + function onPressEvent(e: Event) { + if (pageTarget(e)) swallow(e) } function onKeyDown(e: KeyboardEvent) { if (e.key === 'Escape') { - overlay.hide() lastHovered = null + scheduleUpdate() onEscape?.() } } + // Défilement/redimensionnement : écoutés tant qu'il y a quelque chose à suivre (picking actif + // OU sélection affichée, y compris verrouillée). + let tracking = false + function syncTracking() { + const shouldTrack = active || selected != null + if (shouldTrack === tracking) return + tracking = shouldTrack + if (tracking) { + window.addEventListener('scroll', scheduleUpdate, true) + window.addEventListener('resize', scheduleUpdate) + } else { + window.removeEventListener('scroll', scheduleUpdate, true) + window.removeEventListener('resize', scheduleUpdate) + } + } + return { showSelection(el) { - if (el && el.isConnected) overlay.show(el.getBoundingClientRect(), describeElement(el)) - else overlay.hide() + if (el !== selected) { + resizeObserver.disconnect() + if (el) resizeObserver.observe(el) + selected = el + syncTracking() + } + scheduleUpdate() }, start() { if (active) return active = true document.addEventListener('mousemove', onMouseMove, true) document.addEventListener('click', onClick, true) + for (const type of PRESS_EVENTS) document.addEventListener(type, onPressEvent, true) document.addEventListener('keydown', onKeyDown, true) - window.addEventListener('scroll', onScrollOrResize, true) - window.addEventListener('resize', onScrollOrResize) + syncTracking() }, stop() { if (!active) return active = false document.removeEventListener('mousemove', onMouseMove, true) document.removeEventListener('click', onClick, true) + for (const type of PRESS_EVENTS) document.removeEventListener(type, onPressEvent, true) document.removeEventListener('keydown', onKeyDown, true) - window.removeEventListener('scroll', onScrollOrResize, true) - window.removeEventListener('resize', onScrollOrResize) - if (rafId != null) cancelAnimationFrame(rafId) - overlay.hide() lastHovered = null + syncTracking() + scheduleUpdate() }, } } diff --git a/src/content/picker/overlay.ts b/src/content/picker/overlay.ts deleted file mode 100644 index 6b863ac..0000000 --- a/src/content/picker/overlay.ts +++ /dev/null @@ -1,62 +0,0 @@ -// Implémentation impérative (pas React) : le mousemove est un chemin chaud, on évite -// le coût d'un re-render React à chaque déplacement de souris. Styles posés en inline -// (pas de stylesheet externe à charger) : c'est un tout petit bout d'UI purement visuel. -export interface Overlay { - show(rect: DOMRect, label: string): void - hide(): void - destroy(): void -} - -export function createOverlay(container: ShadowRoot): Overlay { - const box = document.createElement('div') - box.style.cssText = [ - 'position: fixed', - 'pointer-events: none', - 'z-index: 2147483647', - 'border: 2px solid #6366f1', - 'background: rgba(99, 102, 241, 0.15)', - 'box-sizing: border-box', - 'display: none', - ].join(';') - - const label = document.createElement('div') - label.style.cssText = [ - 'position: fixed', - 'pointer-events: none', - 'z-index: 2147483647', - 'background: #6366f1', - 'color: white', - "font: 11px/1.4 system-ui, sans-serif", - 'padding: 2px 6px', - 'border-radius: 4px', - 'white-space: nowrap', - 'display: none', - ].join(';') - - container.appendChild(box) - container.appendChild(label) - - return { - show(rect, text) { - box.style.display = 'block' - box.style.top = `${rect.top}px` - box.style.left = `${rect.left}px` - box.style.width = `${rect.width}px` - box.style.height = `${rect.height}px` - - label.style.display = 'block' - label.textContent = text - const labelTop = rect.top > 20 ? rect.top - 20 : rect.bottom + 2 - label.style.top = `${labelTop}px` - label.style.left = `${rect.left}px` - }, - hide() { - box.style.display = 'none' - label.style.display = 'none' - }, - destroy() { - box.remove() - label.remove() - }, - } -} diff --git a/src/content/preview.ts b/src/content/preview.ts new file mode 100644 index 0000000..910a750 --- /dev/null +++ b/src/content/preview.ts @@ -0,0 +1,37 @@ +import { applyClassEdit } from '../core/class-diff' +import { getClassAttr, setClassAttr } from '../core/class-attr' +import { ensureLiveRule } from './live-injection' +import type { ClassEdit } from '../types' + +// Aperçu au survol d'une valeur dans le panneau : l'édition est appliquée temporairement, sans +// historique, toujours à partir de l'état réel de l'élément (un nouveau survol remplace le +// précédent au lieu de s'y cumuler), puis annulée ou remplacée par l'édition réelle. +let previewEl: Element | null = null +let previewBase: string | null = null + +export function previewEdit(el: Element, edit: ClassEdit): void { + if (previewEl !== el) { + cancelPreview() + previewEl = el + previewBase = el.getAttribute('class') + } else { + restoreBase(el) + } + if (edit.add) ensureLiveRule(edit.add, edit.liveRule) + applyClassEdit(el, edit) +} + +function restoreBase(el: Element) { + if (previewBase === null) el.removeAttribute('class') + else if (getClassAttr(el) !== previewBase) setClassAttr(el, previewBase) +} + +/** Remet l'élément dans son état réel ; à appeler avant toute édition, annulation ou lecture des + * classes. `true` si un aperçu était en cours. */ +export function cancelPreview(): boolean { + if (!previewEl) return false + if (previewEl.isConnected) restoreBase(previewEl) + previewEl = null + previewBase = null + return true +} diff --git a/src/content/shadow-mount.ts b/src/content/shadow-mount.ts index 43a8ba4..74a8e7f 100644 --- a/src/content/shadow-mount.ts +++ b/src/content/shadow-mount.ts @@ -15,7 +15,9 @@ export function mountShadowHost(hostId: string): ShadowMount { host.style.cssText = 'all: initial; position: fixed; inset: 0; z-index: 2147483647; pointer-events: none;' document.documentElement.appendChild(host) - const shadowRoot = host.attachShadow({ mode: 'open' }) + // `closed` : `host.shadowRoot` vaut `null` pour le JS de la page, qui ne peut donc ni lire ni + // modifier l'overlay. On garde la seule référence au root, retournée ci-dessous. + const shadowRoot = host.attachShadow({ mode: 'closed' }) // Le Shadow DOM bloque nativement les collisions de sélecteurs dans les deux sens, // mais pas l'héritage CSS (font-family, color, line-height traversent la frontière) : diff --git a/src/content/sync.ts b/src/content/sync.ts index 661f9e3..cd6fd2b 100644 --- a/src/content/sync.ts +++ b/src/content/sync.ts @@ -1,9 +1,15 @@ -import { addRawClass, applyClassChange, removeRawClass } from '../core/class-diff' -import { detectSitePrefix, scanCustomClasses, watchForStylesheetChanges } from '../core/css-scanner' -import { ensureLiveRule, setSitePrefix } from '../core/live-style' +import { addRawClass, applyClassEdit, removeRawClass } from '../core/class-diff' +import { readClassList } from '../core/class-attr' +import { computeEffectiveColors } from './element-colors' +import { scanStylesheetClasses, watchForStylesheetChanges } from '../core/css-scanner' +import { collectPrefixCandidates } from '../core/prefix-candidates' +import { ensureLiveRule, invalidateRuleIndex, STYLE_ELEMENT_ID } from './live-injection' import { scanThemeVariables } from '../core/theme-scanner' +import { isSyncFromPanel } from '../core/message-guards' +import { clearChangeLog, finalClasses, getChangeLog, logChange, revertEntry } from './change-log' +import { cancelPreview, previewEdit } from './preview' import { DEVWIND_SYNC_PORT } from '../types' -import type { AncestorInfo, ChangeLogEntry, ClassChangeResult, ElementColors, SyncFromContent, SyncFromPanel } from '../types' +import type { AncestorInfo, ClassChangeResult, SyncFromContent, SyncFromPanel } from '../types' // Élément actuellement sélectionné + chaîne de ses ancêtres (fil d'ariane), gardés hors de // tout state React/store (c'est le content script qui a l'accès DOM réel ; la fenêtre devpanel @@ -15,47 +21,15 @@ let stopWatchingStylesheets: (() => void) | null = null const MAX_ANCESTORS = 8 -// Historique des modifications de TOUTE la page pendant la session (pas juste l'élément -// sélectionné) : permet de retrouver l'ensemble des changements faits à différents endroits -// sans avoir à s'en souvenir soi-même. Vidé au rechargement de la page (le content script est -// ré-injecté à zéro), plafonné pour éviter une croissance illimitée sur une session très longue. -const MAX_LOG_ENTRIES = 300 -let changeLog: ChangeLogEntry[] = [] -let changeLogIdCounter = 0 - -function readClasses(el: Element): string[] { - return el.className.toString().split(/\s+/).filter(Boolean) -} - function describeAncestor(el: Element): AncestorInfo { - return { tagName: el.tagName.toLowerCase(), id: el.id || null, classes: readClasses(el) } + return { tagName: el.tagName.toLowerCase(), id: el.id || null, classes: readClassList(el) } } -/** Description légère mais STABLE d'un élément (jamais basée sur ses classes, puisque ce sont - * justement elles qui changent) : id si présent, sinon position parmi ses frères de même tag — - * pas un sélecteur garanti unique, juste de quoi se repérer visuellement dans l'historique. */ -function describeElement(el: Element): string { - const tag = el.tagName.toLowerCase() - if (el.id) return `${tag}#${el.id}` - const parent = el.parentElement - if (!parent) return tag - const siblingsOfSameTag = Array.from(parent.children).filter((c) => c.tagName === el.tagName) - if (siblingsOfSameTag.length <= 1) return tag - return `${tag}:nth-of-type(${siblingsOfSameTag.indexOf(el) + 1})` -} - -/** Diffe un `ClassChangeResult` et l'ajoute à l'historique de session (silencieux si la - * modification n'a en fait rien changé, ex. reposer la même valeur déjà active). */ -function logChange(el: Element, result: ClassChangeResult) { - const before = result.before.split(/\s+/).filter(Boolean) - const after = result.after.split(/\s+/).filter(Boolean) - const added = after.filter((c) => !before.includes(c)) - const removed = before.filter((c) => !after.includes(c)) - if (added.length === 0 && removed.length === 0) return - - changeLog.push({ id: ++changeLogIdCounter, timestamp: Date.now(), elementLabel: describeElement(el), added, removed }) - if (changeLog.length > MAX_LOG_ENTRIES) changeLog = changeLog.slice(changeLog.length - MAX_LOG_ENTRIES) - send({ type: 'CHANGE_LOG_UPDATED', entries: changeLog }) +/** Ajoute la modification à l'historique et en transmet la nouvelle entrée seule (le panneau + * l'ajoute à sa copie, même plafond). */ +function recordChange(el: Element, result: ClassChangeResult) { + const entry = logChange(el, result) + if (entry) send({ type: 'CHANGE_LOG_ENTRY', entry }) } /** Du parent direct jusqu'à `` inclus, plafonné pour éviter un fil d'ariane interminable @@ -71,60 +45,44 @@ function computeAncestors(el: Element): Element[] { return chain } -function isTransparent(color: string): boolean { - const m = /rgba\([^)]+,\s*([\d.]+)\)/.exec(color) - return color === 'transparent' || (m != null && Number(m[1]) === 0) -} - -/** Couleur de fond effective : remonte les ancêtres tant que `background-color` est - * transparent, pour refléter le fond réellement visible derrière l'élément plutôt qu'un - * `rgba(0,0,0,0)` inutile au calcul de contraste. Blanc par défaut si toute la chaîne est - * transparente (cas `` sans fond explicite, comportement de rendu par défaut). */ -function computeEffectiveColors(el: Element): ElementColors { - const style = getComputedStyle(el) - let bg = style.backgroundColor - let current: Element | null = el - while (current && isTransparent(bg)) { - current = current.parentElement - if (!current) break - bg = getComputedStyle(current).backgroundColor - } - if (!bg || isTransparent(bg)) bg = 'rgb(255, 255, 255)' - return { - color: style.color, - backgroundColor: bg, - fontSize: parseFloat(style.fontSize), - bold: Number(style.fontWeight) >= 700, - } -} - function send(message: SyncFromContent) { port?.postMessage(message) } -async function runCssScan() { - // Détection du préfixe synchrone, indépendante des fetch() cross-origin de scanCustomClasses - // (qui peuvent prendre du temps) : évite qu'une classe appliquée pendant ce délai soit - // synthétisée avec le mauvais nom de variable (cf. live-style.ts / theme-scanner.ts). - setSitePrefix(detectSitePrefix()) - const result = await scanCustomClasses() +function sendElementSelected(el: Element) { send({ - type: 'CUSTOM_SCAN_RESULT', - found: Array.from(result.found.entries()), - unscannable: result.unscannable, - detectedPrefix: result.detectedPrefix, + type: 'ELEMENT_SELECTED', + tagName: el.tagName.toLowerCase(), + classes: readClassList(el), + ancestors: ancestorElements.map(describeAncestor), + colors: computeEffectiveColors(el), }) } -function runThemeScan() { - send({ type: 'THEME_SCAN_RESULT', variables: scanThemeVariables(document, detectSitePrefix()) }) +async function runCssScan() { + const result = await scanStylesheetClasses(document, STYLE_ELEMENT_ID) + send({ type: 'CUSTOM_SCAN_RESULT', found: Array.from(result.found.entries()), unscannable: result.unscannable, breakpoints: result.breakpoints }) +} + +/** Un seul parcours du DOM, à la connexion puis à chaque changement de feuilles de style : le + * panneau en déduit le préfixe de site et le garde pour la synthèse et le scan de thème. */ +function sendPrefixCandidates() { + send({ type: 'PREFIX_CANDIDATES', candidates: collectPrefixCandidates() }) +} + +/** Les feuilles de style ont changé : index des vraies règles périmé, préfixe et liste "Custom" + * à recalculer. */ +function onStylesheetsChanged() { + invalidateRuleIndex() + sendPrefixCandidates() + void runCssScan() } export interface SetupSyncOptions { onPortConnected: () => void onPortDisconnected: () => void - /** Sélection changée (picker, fil d'ariane ou navigation clavier) : sert à synchroniser le - * rectangle de surbrillance sur la page avec la sélection actuelle. */ + /** Sélection changée (picker, fil d'ariane ou navigation clavier) ou ses classes modifiées : + * sert à synchroniser le rectangle de surbrillance sur la page avec la sélection actuelle. */ onSelectionChanged: (el: Element | null) => void onSetLocked: (locked: boolean) => void } @@ -133,28 +91,21 @@ let options: SetupSyncOptions | null = null /** Centralise le changement de sélection (picker, fil d'ariane, clavier) : met à jour l'état, * recalcule les ancêtres, notifie la fenêtre devpanel ET le callback local (surbrillance). */ -function setSelection(el: Element | null) { +function setSelection(el: Element | null, detached = false) { + cancelPreview() selectedEl = el ancestorElements = el ? computeAncestors(el) : [] options?.onSelectionChanged(el) - if (!el) { - send({ type: 'ELEMENT_CLEARED' }) - return - } - send({ - type: 'ELEMENT_SELECTED', - tagName: el.tagName.toLowerCase(), - classes: readClasses(el), - ancestors: ancestorElements.map(describeAncestor), - colors: computeEffectiveColors(el), - }) + if (el) sendElementSelected(el) + else send({ type: 'ELEMENT_CLEARED', detached }) } /** Recalcule les couleurs effectives à chaque changement de classes (une édition peut changer * le texte ET le fond, ou le fond d'un ancêtre remonté par `computeEffectiveColors`). */ function sendClassesUpdated(el: Element, unsupportedClass?: string | null) { - send({ type: 'CLASSES_UPDATED', classes: readClasses(el), unsupportedClass, colors: computeEffectiveColors(el) }) + options?.onSelectionChanged(el) + send({ type: 'CLASSES_UPDATED', classes: readClassList(el), unsupportedClass, colors: computeEffectiveColors(el) }) } function navigate(direction: 'parent' | 'child' | 'prev' | 'next') { @@ -172,39 +123,76 @@ function navigate(direction: 'parent' | 'child' | 'prev' | 'next') { setSelection(target) } -function handlePanelMessage(message: SyncFromPanel) { +/** Élément sélectionné encore dans le DOM ? Une SPA qui re-rend le composant le remplace par + * un nouveau nœud : éditer l'ancien n'aurait aucun effet visible, donc on vide la sélection et + * on prévient le panneau plutôt que d'appliquer les éditions dans le vide. */ +function ensureSelectionConnected(): boolean { + if (!selectedEl) return false + if (selectedEl.isConnected) return true + setSelection(null, true) + return false +} + +const NEEDS_SELECTION = new Set(['APPLY_CHANGE', 'PREVIEW_CHANGE', 'REMOVE_CLASS', 'TOGGLE_CLASS', 'NAVIGATE', 'SELECT_ANCESTOR']) +/** Messages qui ne touchent ni aux classes ni à la sélection : un aperçu en cours y survit. */ +const KEEPS_PREVIEW = new Set(['PREVIEW_CHANGE', 'RUN_CSS_SCAN', 'RUN_THEME_SCAN', 'SET_LOCKED']) + +function handlePanelMessage(raw: unknown) { + if (!isSyncFromPanel(raw)) return + const message: SyncFromPanel = raw + // Toute édition/lecture part de l'état réel de l'élément, jamais de l'aperçu. + if (!KEEPS_PREVIEW.has(message.type)) cancelPreview() + if (NEEDS_SELECTION.has(message.type) && !ensureSelectionConnected()) return switch (message.type) { case 'APPLY_CHANGE': { if (!selectedEl) return - let unsupportedClass: string | null = null - if (message.request.newBase) { - const fullClassName = [...message.request.variants, message.request.newBase].join(':') - if (ensureLiveRule(fullClassName) === 'unsupported') unsupportedClass = fullClassName - } - logChange(selectedEl, applyClassChange(selectedEl, message.request)) + const { edit } = message + const unsupportedClass = edit.add && ensureLiveRule(edit.add, edit.liveRule) === 'unsupported' ? edit.add : null + recordChange(selectedEl, applyClassEdit(selectedEl, edit)) sendClassesUpdated(selectedEl, unsupportedClass) return } + case 'PREVIEW_CHANGE': { + if (selectedEl) previewEdit(selectedEl, message.edit) + return + } + case 'CANCEL_PREVIEW': + return // déjà annulé ci-dessus case 'REMOVE_CLASS': { if (!selectedEl) return - logChange(selectedEl, removeRawClass(selectedEl, message.rawClass)) + recordChange(selectedEl, removeRawClass(selectedEl, message.rawClass)) sendClassesUpdated(selectedEl) return } case 'TOGGLE_CLASS': { if (!selectedEl) return - const current = readClasses(selectedEl) + const current = readClassList(selectedEl) const result = current.includes(message.rawClass) ? removeRawClass(selectedEl, message.rawClass) : addRawClass(selectedEl, message.rawClass) - logChange(selectedEl, result) + recordChange(selectedEl, result) sendClassesUpdated(selectedEl) return } + case 'REVERT_CHANGE': { + const outcome = revertEntry(message.id, message.undo, new Map(message.liveRules)) + if (!outcome) return + if (!outcome.ok) { + send({ type: 'REVERT_REJECTED', id: message.id, reason: outcome.reason }) + return + } + send({ type: 'CHANGE_LOG_ENTRY_UPDATED', entry: outcome.entry }) + if (outcome.el === selectedEl) sendClassesUpdated(selectedEl) + return + } + case 'REQUEST_FINAL_CLASSES': { + send({ type: 'FINAL_CLASSES', elements: finalClasses() }) + return + } case 'RUN_CSS_SCAN': { void runCssScan() return } case 'RUN_THEME_SCAN': { - runThemeScan() + send({ type: 'THEME_SCAN_RESULT', variables: scanThemeVariables(document, message.prefix) }) return } case 'SELECT_ANCESTOR': { @@ -221,8 +209,8 @@ function handlePanelMessage(message: SyncFromPanel) { return } case 'CLEAR_CHANGE_LOG': { - changeLog = [] - send({ type: 'CHANGE_LOG_UPDATED', entries: changeLog }) + clearChangeLog() + send({ type: 'CHANGE_LOG_RESET', entries: getChangeLog() }) return } } @@ -232,33 +220,33 @@ function handlePanelMessage(message: SyncFromPanel) { export function setupSync(opts: SetupSyncOptions) { options = opts chrome.runtime.onConnect.addListener((p) => { - if (p.name !== DEVWIND_SYNC_PORT) return + // Seule NOTRE fenêtre devpanel (même id d'extension) a le droit de piloter la page. + if (p.name !== DEVWIND_SYNC_PORT || p.sender?.id !== chrome.runtime.id) return port = p opts.onPortConnected() - if (selectedEl) { - send({ - type: 'ELEMENT_SELECTED', - tagName: selectedEl.tagName.toLowerCase(), - classes: readClasses(selectedEl), - ancestors: ancestorElements.map(describeAncestor), - colors: computeEffectiveColors(selectedEl), - }) - } + // Avant la sélection : le panneau connaît ainsi le préfixe avant ses premières requêtes + // (scan de thème, synthèse), les messages du Port arrivant dans l'ordre. + invalidateRuleIndex() + sendPrefixCandidates() + if (selectedEl) sendElementSelected(selectedEl) + // Surbrillance masquée à la fermeture du panneau précédent : on la réaffiche. + opts.onSelectionChanged(selectedEl) // Toujours renvoyé, même vide : une fenêtre devpanel réouverte doit refléter l'historique // déjà accumulé cette session (le content script, lui, n'est pas ré-injecté à chaque // ouverture du panneau). - send({ type: 'CHANGE_LOG_UPDATED', entries: changeLog }) + send({ type: 'CHANGE_LOG_RESET', entries: getChangeLog() }) // Re-scanne automatiquement (debounced) si le site charge une feuille de style après coup // (route SPA, composant lazy-loadé...), pour ne pas laisser la liste "Custom" périmée tant // que la fenêtre devpanel reste ouverte. stopWatchingStylesheets?.() - stopWatchingStylesheets = watchForStylesheetChanges(() => void runCssScan()) + stopWatchingStylesheets = watchForStylesheetChanges(onStylesheetsChanged, document, STYLE_ELEMENT_ID) p.onMessage.addListener(handlePanelMessage) p.onDisconnect.addListener(() => { if (port === p) port = null + cancelPreview() stopWatchingStylesheets?.() stopWatchingStylesheets = null opts.onPortDisconnected() @@ -271,7 +259,7 @@ export function selectElement(el: Element | null) { setSelection(el) } -/** Appelé quand le verrouillage est déclenché depuis LA PAGE (Échap, cf. main.tsx) plutôt que +/** Appelé quand le verrouillage est déclenché depuis LA PAGE (Échap, cf. main.ts) plutôt que * depuis le bouton 🔒 du panneau : contrairement à `SET_LOCKED` (panneau → page), il n'y a rien * à appliquer côté page ici (déjà fait par l'appelant), juste à synchroniser l'icône du panneau. */ export function notifyLockedFromPage(locked: boolean) { diff --git a/src/core/activation.ts b/src/core/activation.ts index c71d162..9b46514 100644 --- a/src/core/activation.ts +++ b/src/core/activation.ts @@ -42,12 +42,18 @@ async function setPickerActive(tabId: number, active: boolean): Promise { } } +interface DevPanelLocation { + windowId: number + /** Onglet de la fenêtre devpanel elle-même (pas l'onglet de la page éditée). */ + panelTabId: number +} + /** Retrouve la fenêtre devpanel déjà ouverte pour cet onglet, s'il y en a une. */ -async function findDevPanelWindowId(tabId: number): Promise { +async function findDevPanel(tabId: number): Promise { const tabs = await chrome.tabs.query({ url: `${chrome.runtime.getURL(DEVPANEL_PATH)}*` }) for (const t of tabs) { - if (!t.url || t.windowId == null) continue - if (new URL(t.url).searchParams.get('tabId') === String(tabId)) return t.windowId + if (!t.url || t.windowId == null || t.id == null) continue + if (new URL(t.url).searchParams.get('tabId') === String(tabId)) return { windowId: t.windowId, panelTabId: t.id } } return null } @@ -59,9 +65,22 @@ interface WindowBounds { height?: number } +const BOUND_KEYS = ['left', 'top', 'width', 'height'] as const +const MIN_WINDOW_SIZE = 200 + +/** Relit la position sauvegardée en ne gardant que des entiers plausibles : une valeur + * corrompue ferait échouer `chrome.windows.create` (et donc l'ouverture du panneau). */ async function getSavedBounds(): Promise { - const stored = await chrome.storage.local.get(BOUNDS_STORAGE_KEY) - return (stored[BOUNDS_STORAGE_KEY] as WindowBounds | undefined) ?? {} + const stored: unknown = (await chrome.storage.local.get(BOUNDS_STORAGE_KEY))[BOUNDS_STORAGE_KEY] + if (typeof stored !== 'object' || stored === null) return {} + const bounds: WindowBounds = {} + for (const key of BOUND_KEYS) { + const v = (stored as Record)[key] + if (typeof v !== 'number' || !Number.isFinite(v)) continue + if ((key === 'width' || key === 'height') && v < MIN_WINDOW_SIZE) continue + bounds[key] = Math.round(v) + } + return bounds } /** Appelé par la fenêtre devpanel elle-même (resize/blur) pour se souvenir de sa position. */ @@ -75,9 +94,9 @@ export async function openDevPanel(tabId: number): Promise { if (!ready) return await setPickerActive(tabId, true) - const existing = await findDevPanelWindowId(tabId) + const existing = await findDevPanel(tabId) if (existing != null) { - await chrome.windows.update(existing, { focused: true }) + await chrome.windows.update(existing.windowId, { focused: true }) return } @@ -99,13 +118,58 @@ export async function openDevPanel(tabId: number): Promise { * explicite à setPickerActive ici est un filet de sécurité, pas le mécanisme principal. */ export async function closeDevPanel(tabId: number): Promise { - const existing = await findDevPanelWindowId(tabId) - if (existing != null) await chrome.windows.remove(existing) + const existing = await findDevPanel(tabId) + if (existing != null) await chrome.windows.remove(existing.windowId) await setPickerActive(tabId, false) } +/** + * Page rechargée/naviguée : le content script a disparu et la fenêtre devpanel affiche "Page + * fermée". Plutôt que de fermer ce panneau mort (ce qui obligeait à cliquer deux fois), on + * réinjecte le content script puis on recharge le panneau, qui se reconnecte au démarrage. + */ +async function reconnectDevPanel(tabId: number, panel: DevPanelLocation): Promise { + const ready = await ensureContentScriptReady(tabId) + if (!ready) return + await setPickerActive(tabId, true) + await chrome.tabs.reload(panel.panelTabId) + await chrome.windows.update(panel.windowId, { focused: true }) +} + +/** + * Bouton "Reconnecter" du panneau : réinjecte le content script et réactive le picker. Échoue + * (`false`) quand Chrome a révoqué `activeTab` à la navigation — seul un nouveau geste sur + * l'icône (ou le raccourci) peut alors rendre l'accès à la page. + */ +export async function reinjectContentScript(tabId: number): Promise { + try { + if (!(await ensureContentScriptReady(tabId))) return false + await setPickerActive(tabId, true) + return true + } catch { + return false + } +} + +/** Met l'onglet de la page au premier plan (et sa fenêtre), pour cliquer sur l'icône DevWind. */ +export async function focusPageTab(tabId: number): Promise { + try { + const tab = await chrome.tabs.update(tabId, { active: true }) + if (tab?.windowId != null) await chrome.windows.update(tab.windowId, { focused: true }) + } catch { + // Onglet fermé entre-temps : rien à afficher. + } +} + export async function toggleDevPanel(tabId: number): Promise { - const existing = await findDevPanelWindowId(tabId) - if (existing != null) await closeDevPanel(tabId) - else await openDevPanel(tabId) + const existing = await findDevPanel(tabId) + if (existing == null) return openDevPanel(tabId) + if (await pingContentScript(tabId)) return closeDevPanel(tabId) + return reconnectDevPanel(tabId, existing) +} + +/** Onglet de la page fermé : sa fenêtre devpanel n'a plus rien à éditer, on la ferme aussi. */ +export async function closeDevPanelOfRemovedTab(tabId: number): Promise { + const existing = await findDevPanel(tabId) + if (existing != null) await chrome.windows.remove(existing.windowId) } diff --git a/src/core/arbitrary-value.ts b/src/core/arbitrary-value.ts new file mode 100644 index 0000000..e2c59c5 --- /dev/null +++ b/src/core/arbitrary-value.ts @@ -0,0 +1,17 @@ +/** + * Convention Tailwind pour les valeurs arbitraires : une classe ne peut pas contenir d'espace, + * donc `_` représente un espace (`grid-cols-[1fr_2fr]` -> `1fr 2fr`), `\_` un vrai underscore, + * et les underscores d'une `url(...)` sont conservés tels quels (chemins de fichiers). + */ +export function decodeArbitraryValue(raw: string): string { + return raw + .split(/(url\([^)]*\))/) + .map((part, i) => (i % 2 === 1 ? part : part.replace(/\\_|_/g, (m) => (m === '_' ? ' ' : '_')))) + .join('') +} + +/** Retire une paire de guillemets englobante (`"open"` -> `open`), sinon renvoie tel quel. */ +export function unquote(value: string): string { + const m = /^(['"])(.*)\1$/.exec(value) + return m ? m[2] : value +} diff --git a/src/core/breakpoint-scanner.ts b/src/core/breakpoint-scanner.ts new file mode 100644 index 0000000..b34616a --- /dev/null +++ b/src/core/breakpoint-scanner.ts @@ -0,0 +1,56 @@ +import { extractClassSelectors, isGroupingRule, isRuleWithSelector } from './css-selectors' +import { splitVariants } from './split-variants' +import type { BreakpointVote } from '../types' + +// Côté page : recense, dans les media queries de largeur du CSS du site, quels variants de tête +// elles accompagnent — `@media (width >= 40rem) { .sm\:flex {…} }` vote `sm = 40rem`. Tailwind +// v4 n'émet pas les variables `--breakpoint-*` (aucune utilité ne les référence via `var()`), +// ces media queries sont donc la seule trace fiable des breakpoints personnalisés. Le panneau, +// qui connaît le préfixe de site, en déduit les noms (cf. `breakpointsFromVotes`). + +// v4 : `(width >= X)` / `(width < X)` ; v3 : `(min-width: X)`. Le `max-*` v3 (`not all and +// (min-width: X)`) n'est pas reconnu : le seuil min du même breakpoint suffit. +const MIN_RE = /^\(\s*(?:min-width\s*:|width\s*>=)\s*([\d.]+(?:px|rem|em))\s*\)$/ +const MAX_RE = /^\(\s*width\s*<\s*([\d.]+(?:px|rem|em))\s*\)$/ + +/** Plafond de votes distincts : quelques breakpoints × variantes de préfixe suffisent largement. */ +const MAX_VOTES = 200 + +function widthCondition(rule: CSSRule): { op: 'min' | 'max'; length: string } | null { + if (!('media' in rule)) return null + const text = ((rule as CSSMediaRule).conditionText ?? (rule as CSSMediaRule).media.mediaText).trim() + const min = MIN_RE.exec(text) + if (min) return { op: 'min', length: min[1] } + const max = MAX_RE.exec(text) + return max ? { op: 'max', length: max[1] } : null +} + +function voteForRules(rules: CSSRuleList, op: 'min' | 'max', length: string, votes: Map) { + for (const rule of Array.from(rules)) { + if (isRuleWithSelector(rule)) { + for (const cls of extractClassSelectors(rule.selectorText)) { + const { variants } = splitVariants(cls) + if (variants.length === 0) continue + const [first, second = ''] = variants + const key = `${first}\n${second}\n${op}\n${length}` + const vote = votes.get(key) + if (vote) vote[4]++ + else if (votes.size < MAX_VOTES) votes.set(key, [first, second, op, length, 1]) + } + } + if (isGroupingRule(rule) && rule.cssRules.length > 0) voteForRules(rule.cssRules, op, length, votes) + } +} + +/** Parcourt `rules` ; seule la media query de largeur la plus externe vote (celle du premier + * variant de la classe, Tailwind imbriquant les variants dans leur ordre d'écriture). */ +export function collectBreakpointVotes(rules: CSSRuleList, votes: Map): void { + for (const rule of Array.from(rules)) { + const condition = widthCondition(rule) + if (condition && isGroupingRule(rule)) { + voteForRules(rule.cssRules, condition.op, condition.length, votes) + continue + } + if (isGroupingRule(rule) && rule.cssRules.length > 0) collectBreakpointVotes(rule.cssRules, votes) + } +} diff --git a/src/core/breakpoints.ts b/src/core/breakpoints.ts new file mode 100644 index 0000000..76158f7 --- /dev/null +++ b/src/core/breakpoints.ts @@ -0,0 +1,73 @@ +import type { BreakpointVote } from '../types' + +/** Nom de breakpoint -> longueur CSS de son seuil (`40rem`, `768px`...), telle que le site l'écrit. */ +export type BreakpointMap = Readonly> + +// Breakpoints par défaut de Tailwind v4 (en rem, comme le CSS qu'il génère). Remplacés/complétés +// par ceux réellement utilisés par le site (media queries de son CSS, cf. breakpoint-scanner.ts) +// dès que le panneau les connaît. Source unique pour la synthèse (variant-plan.ts), la détection +// de préfixe de site (prefix-candidates.ts) et la toolbar de variants du panneau. +export const DEFAULT_BREAKPOINTS: BreakpointMap = { sm: '40rem', md: '48rem', lg: '64rem', xl: '80rem', '2xl': '96rem' } + +export function isDefaultBreakpoint(name: string): boolean { + return Object.hasOwn(DEFAULT_BREAKPOINTS, name) +} + +const LENGTH_RE = /^(\d+(?:\.\d+)?|\.\d+)(px|rem|em)$/ + +export function isBreakpointLength(value: string): boolean { + return LENGTH_RE.test(value) +} + +/** Seuil approximatif en px (rem/em à 16px, comme une media query) : sert à trier, pas à rendre. */ +export function breakpointPx(length: string): number { + const m = LENGTH_RE.exec(length) + if (!m) return Number.POSITIVE_INFINITY + return m[2] === 'px' ? Number(m[1]) : Number(m[1]) * 16 +} + +/** Noms triés du plus petit au plus grand seuil (ordre d'affichage de la toolbar). */ +export function sortedBreakpointNames(map: BreakpointMap): string[] { + return Object.keys(map).sort((a, b) => breakpointPx(map[a]) - breakpointPx(map[b])) +} + +const NAME_RE = /^[a-z0-9][a-z0-9-]*$/ + +/** + * Breakpoints réellement utilisés par le site, déduits des votes collectés dans ses media + * queries (cf. breakpoint-scanner.ts) : `(width >= X)` autour de `.sm\:…` vote `sm = X`, + * `(width < X)` autour de `.max-sm\:…` aussi. `sitePrefix` (`tw:sm:flex`) : le breakpoint est + * alors le second variant. Pour un même nom, le seuil le plus fréquent l'emporte. + */ +export function breakpointsFromVotes(votes: BreakpointVote[], sitePrefix: string | null): Record { + const tally = new Map>() + for (const [first, second, op, length, count] of votes) { + let name = sitePrefix && first === sitePrefix ? second : first + if (op === 'max') { + if (!name.startsWith('max-')) continue + name = name.slice(4) + } + if (!NAME_RE.test(name) || name.startsWith('max-') || !isBreakpointLength(length)) continue + const lengths = tally.get(name) ?? new Map() + lengths.set(length, (lengths.get(length) ?? 0) + count) + tally.set(name, lengths) + } + const result: Record = {} + for (const [name, lengths] of tally) { + result[name] = [...lengths.entries()].sort((a, b) => b[1] - a[1])[0][0] + } + return result +} + +/** Variables `--breakpoint-*` du thème (présentes seulement avec `@theme static` : Tailwind v4 + * n'émet pas les variables qu'aucune utilité ne référence via `var()`). */ +export function breakpointsFromThemeVariables(variables: { name: string; value: string }[], sitePrefix: string | null): Record { + const namespace = sitePrefix ? `--${sitePrefix}-breakpoint-` : '--breakpoint-' + const result: Record = {} + for (const { name, value } of variables) { + if (!name.startsWith(namespace)) continue + const bp = name.slice(namespace.length) + if (NAME_RE.test(bp) && isBreakpointLength(value)) result[bp] = value + } + return result +} diff --git a/src/core/class-attr.ts b/src/core/class-attr.ts new file mode 100644 index 0000000..7fca4de --- /dev/null +++ b/src/core/class-attr.ts @@ -0,0 +1,15 @@ +// `el.className` d'un élément SVG est un `SVGAnimatedString`, pas une string (`split` plante, +// `toString()` donne "[object SVGAnimatedString]") : on passe toujours par l'attribut `class`, +// qui se comporte de la même façon sur les éléments HTML et SVG. + +export function getClassAttr(el: Element): string { + return el.getAttribute('class') ?? '' +} + +export function setClassAttr(el: Element, value: string): void { + el.setAttribute('class', value) +} + +export function readClassList(el: Element): string[] { + return getClassAttr(el).split(/\s+/).filter(Boolean) +} diff --git a/src/core/class-diff.ts b/src/core/class-diff.ts index d0a5d59..67f297b 100644 --- a/src/core/class-diff.ts +++ b/src/core/class-diff.ts @@ -1,53 +1,40 @@ -import { matchTaxonomy, splitVariants } from './class-parser' -import type { ClassChangeRequest, ClassChangeResult } from '../types' +import { getClassAttr, readClassList, setClassAttr } from './class-attr' +import type { ClassChangeResult, ClassEdit } from '../types' -export type { ClassChangeRequest, ClassChangeResult } +// Écritures DOM côté content script, sans taxonomie ni dataset : le panneau calcule le diff +// (cf. class-edit-plan.ts) et la page se contente de l'appliquer. -function sameVariantSet(a: string[], b: string[]): boolean { - if (a.length !== b.length) return false - const sortedA = [...a].sort() - const sortedB = [...b].sort() - return sortedA.every((v, i) => v === sortedB[i]) +/** Remplace la liste de classes de l'élément (via l'attribut `class`, cf. class-attr.ts). */ +function writeClasses(el: Element, before: string, next: string[]): ClassChangeResult { + setClassAttr(el, next.join(' ')) + return { before, after: getClassAttr(el) } } -/** - * Retire toutes les classes appartenant au même "slot" (même entrée de taxonomie + - * même contexte de variant) puis ajoute la nouvelle classe. Dédoublonne au passage - * les conflits déjà présents sur l'élément (ex. deux classes `bg-*` en même temps). - */ -export function applyClassChange(el: Element, request: ClassChangeRequest): ClassChangeResult { - const before = el.className - const current = before.split(/\s+/).filter(Boolean) - - const kept = current.filter((raw) => { - const { variants, base } = splitVariants(raw) - if (!sameVariantSet(variants, request.variants)) return true - const match = matchTaxonomy(base) - if (!match || match.entry.id !== request.taxonomyId) return true - return match.prefix !== request.prefix - }) - - const next = request.newBase - ? [...kept, [...request.variants, request.newBase].join(':')] - : kept +/** Retire les classes `remove` (même slot, calculé par le panneau) puis ajoute `add`. */ +export function applyClassEdit(el: Element, edit: Pick): ClassChangeResult { + const before = getClassAttr(el) + const kept = readClassList(el).filter((c) => !edit.remove.includes(c)) + return writeClasses(el, before, edit.add ? [...kept, edit.add] : kept) +} - el.className = next.join(' ') - return { before, after: el.className } +/** Retire `remove` puis ajoute `add` (sans doublon) : annulation/rétablissement d'une entrée + * de l'historique, qui peut concerner plusieurs classes à la fois. */ +export function applyClassDiff(el: Element, remove: string[], add: string[]): ClassChangeResult { + const before = getClassAttr(el) + const kept = readClassList(el).filter((c) => !remove.includes(c)) + return writeClasses(el, before, [...kept, ...add.filter((c) => !kept.includes(c))]) } /** Retire une classe brute précise (ex. suppression d'un chip), sans passer par la taxonomie. */ export function removeRawClass(el: Element, rawClass: string): ClassChangeResult { - const before = el.className - const next = before.split(/\s+/).filter((c) => c && c !== rawClass) - el.className = next.join(' ') - return { before, after: el.className } + const before = getClassAttr(el) + return writeClasses(el, before, readClassList(el).filter((c) => c !== rawClass)) } /** Ajoute une classe brute précise (ex. classe custom cochée dans le panneau), sans doublon. */ export function addRawClass(el: Element, rawClass: string): ClassChangeResult { - const before = el.className - const current = before.split(/\s+/).filter(Boolean) + const before = getClassAttr(el) + const current = readClassList(el) if (!current.includes(rawClass)) current.push(rawClass) - el.className = current.join(' ') - return { before, after: el.className } + return writeClasses(el, before, current) } diff --git a/src/core/class-edit-plan.ts b/src/core/class-edit-plan.ts new file mode 100644 index 0000000..4204e13 --- /dev/null +++ b/src/core/class-edit-plan.ts @@ -0,0 +1,26 @@ +import { matchTaxonomy, splitVariants } from './class-parser' +import { joinVariants } from './split-variants' +import type { ClassChangeRequest, ClassEdit } from '../types' + +function sameVariantSet(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false + const sortedA = [...a].sort() + const sortedB = [...b].sort() + return sortedA.every((v, i) => v === sortedB[i]) +} + +/** + * Côté panneau : classes à retirer (toutes celles du même "slot" — même entrée de taxonomie, + * même préfixe, même contexte de variant) et classe à ajouter. Dédoublonne au passage les + * conflits déjà présents sur l'élément (ex. deux classes `bg-*` en même temps). Appliqué tel + * quel par le content script (cf. class-diff.ts, `applyClassEdit`). + */ +export function planClassEdit(classes: string[], request: ClassChangeRequest): Pick { + const remove = classes.filter((raw) => { + const { variants, base } = splitVariants(raw) + if (!sameVariantSet(variants, request.variants)) return false + const match = matchTaxonomy(base) + return match !== null && match.entry.id === request.taxonomyId && match.prefix === request.prefix + }) + return { remove, add: request.newBase ? joinVariants(request.variants, request.newBase) : null } +} diff --git a/src/core/class-parser.ts b/src/core/class-parser.ts index f4e143a..dcfa536 100644 --- a/src/core/class-parser.ts +++ b/src/core/class-parser.ts @@ -1,6 +1,7 @@ import { taxonomy } from '../data/taxonomy' -import generatedClasses from '../data/generated/tailwind-classes-slim.json' -import type { ParsedClass, SlimGeneratedClass, TaxonomyEntry } from '../types' +import { classes } from '../data/classes' +import { splitVariants } from './split-variants' +import type { TaxonomyEntry } from '../types' /** * Classes réellement générées (cf. scripts/generate-tailwind-data.ts), regroupées par entrée @@ -11,33 +12,13 @@ import type { ParsedClass, SlimGeneratedClass, TaxonomyEntry } from '../types' * tort un candidat textColor également (les deux entrées partagent le préfixe `text`). */ const VALID_CLASS_NAMES_BY_ENTRY = new Map>() -for (const c of generatedClasses as SlimGeneratedClass[]) { +for (const c of classes) { const set = VALID_CLASS_NAMES_BY_ENTRY.get(c.taxonomyId) ?? new Set() set.add(c.className) VALID_CLASS_NAMES_BY_ENTRY.set(c.taxonomyId, set) } -/** - * Découpe une classe en variants + base, en respectant la profondeur de crochets - * (une valeur arbitraire comme `bg-[url(https://x:80)]` contient un `:` qui n'est - * pas un séparateur de variant). - */ -export function splitVariants(cls: string): ParsedClass { - const parts: string[] = [] - let depth = 0 - let start = 0 - for (let i = 0; i < cls.length; i++) { - const c = cls[i] - if (c === '[') depth++ - else if (c === ']') depth-- - else if (c === ':' && depth === 0) { - parts.push(cls.slice(start, i)) - start = i + 1 - } - } - parts.push(cls.slice(start)) - return { raw: cls, variants: parts.slice(0, -1), base: parts[parts.length - 1] } -} +export { splitVariants } export interface ClassMatch { entry: TaxonomyEntry @@ -53,6 +34,43 @@ function toClassName(prefix: string, suffix: string, isNegative: boolean): strin return isNegative ? `-${base}` : base } +function firstSegment(s: string): string { + const dash = s.indexOf('-') + return dash === -1 ? s : s.slice(0, dash) +} + +interface TaxonomyIndex { + /** Classe static -> correspondance (première dans l'ordre entrées × préfixes × valeurs). */ + statics: Map + /** Couples (entrée non static, préfixe) groupés par premier segment du préfixe, dans l'ordre + * de la taxonomie. Un candidat ne peut matcher que si la classe est `préfixe` ou commence par + * `préfixe-` : les deux partagent alors forcément ce premier segment. */ + dynamicBySegment: Map +} + +function buildIndex(entries: TaxonomyEntry[]): TaxonomyIndex { + const statics = new Map() + const dynamicBySegment = new Map() + for (const entry of entries) { + for (const prefix of entry.prefixes) { + if (entry.type === 'static') { + for (const value of entry.staticValues ?? []) { + const cls = prefix === '' ? value : `${prefix}-${value}` + if (!statics.has(cls)) statics.set(cls, { entry, prefix, suffix: value, isArbitrary: false, isNegative: false }) + } + } else if (prefix !== '') { + const segment = firstSegment(prefix) + const bucket = dynamicBySegment.get(segment) ?? [] + bucket.push({ entry, prefix }) + dynamicBySegment.set(segment, bucket) + } + } + } + return { statics, dynamicBySegment } +} + +const defaultIndex = buildIndex(taxonomy) + /** * Fait correspondre une classe "base" (sans variants) à son entrée de taxonomie. * Les entrées `static` sont testées avant les entrées `scale`/`color` (ex. `text-left` @@ -63,67 +81,53 @@ function toClassName(prefix: string, suffix: string, isNegative: boolean): strin * un préfixe par coïncidence (`my-custom-btn`) avec une vraie classe Tailwind. */ export function matchTaxonomy(base: string, entries: TaxonomyEntry[] = taxonomy): ClassMatch | null { + const index = entries === taxonomy ? defaultIndex : buildIndex(entries) const isNegative = base.startsWith('-') const working = isNegative ? base.slice(1) : base - for (const entry of entries) { - if (entry.type !== 'static') continue - if (isNegative) continue - for (const prefix of entry.prefixes) { - for (const value of entry.staticValues ?? []) { - const cls = prefix === '' ? value : `${prefix}-${value}` - if (working === cls) { - return { entry, prefix, suffix: value, isArbitrary: false, isNegative: false } - } - } - } + if (!isNegative) { + const staticMatch = index.statics.get(working) + if (staticMatch) return staticMatch } - for (const entry of entries) { - if (entry.type === 'static') continue + for (const { entry, prefix } of index.dynamicBySegment.get(firstSegment(working)) ?? []) { if (isNegative && !entry.supportsNegative) continue - for (const prefix of entry.prefixes) { - if (prefix === '') continue - - // Forme nue (clé de thème `DEFAULT`, ex. `rounded`/`shadow`/`border`/`ring`) : la classe - // est le préfixe seul, sans tiret-suffixe. - if (!isNegative && working === prefix) { - const validNames = VALID_CLASS_NAMES_BY_ENTRY.get(entry.id) - if (validNames?.has(toClassName(prefix, '', false))) { - return { entry, prefix, suffix: '', isArbitrary: false, isNegative: false } - } - } + const validNames = VALID_CLASS_NAMES_BY_ENTRY.get(entry.id) - const dashPrefix = `${prefix}-` - if (!working.startsWith(dashPrefix)) continue - const suffix = working.slice(dashPrefix.length) - if (!suffix) continue - - // Le `/NN` optionnel gère les valeurs arbitraires avec modificateur d'opacité - // (`bg-[#ff0000]/50`) : sans lui, ce candidat ne matcherait aucune entrée (le dataset - // généré ne contient que les classes sans opacité) et class-diff ne retirerait jamais - // l'ancienne classe arbitraire au profit de la nouvelle (pas le même "slot"). - const arbitraryMatch = /^\[(.+)\](?:\/(\d{1,3}))?$/.exec(suffix) - if (arbitraryMatch) { - if (!entry.supportsArbitrary) continue - const fullSuffix = arbitraryMatch[1] + (arbitraryMatch[2] ? `/${arbitraryMatch[2]}` : '') - return { entry, prefix, suffix: fullSuffix, isArbitrary: true, isNegative } - } + // Forme nue (clé de thème `DEFAULT`, ex. `rounded`/`shadow`/`border`/`ring`) : la classe + // est le préfixe seul, sans tiret-suffixe. + if (!isNegative && working === prefix && validNames?.has(toClassName(prefix, '', false))) { + return { entry, prefix, suffix: '', isArbitrary: false, isNegative: false } + } - const validNames = VALID_CLASS_NAMES_BY_ENTRY.get(entry.id) - if (validNames?.has(toClassName(prefix, suffix, isNegative))) { - return { entry, prefix, suffix, isArbitrary: false, isNegative } - } + const dashPrefix = `${prefix}-` + if (!working.startsWith(dashPrefix)) continue + const suffix = working.slice(dashPrefix.length) + if (!suffix) continue - // Modificateur d'opacité (`bg-red-500/80`) : le dataset généré ne contient que les - // classes de base sans `/NN`, donc on retente sans ce suffixe avant de conclure à - // "non reconnu" — sinon une classe couleur avec opacité n'est jamais reconnue comme - // "même slot" par class-diff, et l'ancienne classe n'est jamais retirée au clic. - if (entry.type === 'color') { - const opacityMatch = /^(.+)\/\d{1,3}$/.exec(suffix) - if (opacityMatch && validNames?.has(toClassName(prefix, opacityMatch[1], isNegative))) { - return { entry, prefix, suffix, isArbitrary: false, isNegative } - } + // Le `/NN` optionnel gère les valeurs arbitraires avec modificateur d'opacité + // (`bg-[#ff0000]/50`) : sans lui, ce candidat ne matcherait aucune entrée (le dataset + // généré ne contient que les classes sans opacité) et le diff ne retirerait jamais + // l'ancienne classe arbitraire au profit de la nouvelle (pas le même "slot"). + const arbitraryMatch = /^\[(.+)\](?:\/(\d{1,3}))?$/.exec(suffix) + if (arbitraryMatch) { + if (!entry.supportsArbitrary) continue + const fullSuffix = arbitraryMatch[1] + (arbitraryMatch[2] ? `/${arbitraryMatch[2]}` : '') + return { entry, prefix, suffix: fullSuffix, isArbitrary: true, isNegative } + } + + if (validNames?.has(toClassName(prefix, suffix, isNegative))) { + return { entry, prefix, suffix, isArbitrary: false, isNegative } + } + + // Modificateur d'opacité (`bg-red-500/80`) : le dataset généré ne contient que les + // classes de base sans `/NN`, donc on retente sans ce suffixe avant de conclure à + // "non reconnu" — sinon une classe couleur avec opacité n'est jamais reconnue comme + // "même slot" par le diff, et l'ancienne classe n'est jamais retirée au clic. + if (entry.type === 'color') { + const opacityMatch = /^(.+)\/\d{1,3}$/.exec(suffix) + if (opacityMatch && validNames?.has(toClassName(prefix, opacityMatch[1], isNegative))) { + return { entry, prefix, suffix, isArbitrary: false, isNegative } } } } @@ -131,7 +135,7 @@ export function matchTaxonomy(base: string, entries: TaxonomyEntry[] = taxonomy) return null } -export function parseClass(cls: string, entries: TaxonomyEntry[] = taxonomy): { parsed: ParsedClass; match: ClassMatch | null } { - const parsed = splitVariants(cls) - return { parsed, match: matchTaxonomy(parsed.base, entries) } +/** Classe (avec variants éventuels) dont la base est reconnue comme utilitaire Tailwind. */ +export function isTailwindClass(className: string): boolean { + return matchTaxonomy(splitVariants(className).base) !== null } diff --git a/src/core/color-alpha.ts b/src/core/color-alpha.ts new file mode 100644 index 0000000..8c12fbe --- /dev/null +++ b/src/core/color-alpha.ts @@ -0,0 +1,23 @@ +function parseAlphaToken(token: string): number { + const value = parseFloat(token) + if (!Number.isFinite(value)) return 1 + const alpha = token.trim().endsWith('%') ? value / 100 : value + return Math.min(1, Math.max(0, alpha)) +} + +/** Canal alpha (0-1) d'une couleur CSS telle que sérialisée par `getComputedStyle` : + * `rgba(r, g, b, a)` (syntaxe à virgules) ou `oklch(L C H / a)`, `color(srgb … / a)`… (syntaxe + * moderne). Sans alpha explicite, la couleur est opaque. Pur, sans DOM : utilisable côté content + * script pour savoir où s'arrêter en remontant les fonds des ancêtres. */ +export function colorAlpha(color: string): number { + const c = color.trim().toLowerCase() + if (c === 'transparent') return 0 + const slash = /\/\s*([\d.]+%?)\s*\)\s*$/.exec(c) + if (slash) return parseAlphaToken(slash[1]) + const legacy = /^(?:rgba?|hsla?)\(([^)]*)\)$/.exec(c) + if (legacy) { + const parts = legacy[1].split(',') + if (parts.length === 4) return parseAlphaToken(parts[3]) + } + return 1 +} diff --git a/src/core/composite.ts b/src/core/composite.ts new file mode 100644 index 0000000..6fab110 --- /dev/null +++ b/src/core/composite.ts @@ -0,0 +1,70 @@ +// --- Propriétés composites (scale / translate / skew / filter / backdrop-filter) --- +// +// Tailwind combine plusieurs classes indépendantes sur une même propriété via des variables CSS +// partagées (ex. `scale-105` et `skew-y-3` doivent affecter leurs propriétés respectives sans +// s'écraser si une troisième classe les recombine). Chaque classe composite pose SA variable ET +// réaffirme la formule complète de la propriété partagée — exactement le CSS que Tailwind génère +// lui-même. Fallback (`var(--x, defaut)`) dans chaque référence pour rester correct même sur un +// site sans preflight Tailwind. NOTE v4 (vérifié) : `rotate`/`scale`/`translate` sont maintenant +// des propriétés CSS natives séparées (plus un seul `transform` composite comme en v3) — seul +// `skew` reste sur `transform` (CSS n'a pas de propriété `skew` native). `rotate` n'a donc plus +// besoin d'être composite du tout (voir son entrée directe dans taxonomy.ts, propriété `rotate`). +function transformFormula(): string { + return 'var(--tw-skew-x,) var(--tw-skew-y,)' +} + +const FILTER_VARS = ['--tw-blur', '--tw-brightness', '--tw-contrast', '--tw-grayscale', '--tw-hue-rotate', '--tw-invert', '--tw-saturate', '--tw-sepia', '--tw-drop-shadow'] +function filterFormula(): string { + return FILTER_VARS.map((v) => `var(${v},)`).join(' ') +} + +const BACKDROP_FILTER_VARS = ['--tw-backdrop-blur', '--tw-backdrop-brightness', '--tw-backdrop-contrast', '--tw-backdrop-grayscale', '--tw-backdrop-hue-rotate', '--tw-backdrop-invert', '--tw-backdrop-opacity', '--tw-backdrop-saturate', '--tw-backdrop-sepia'] +function backdropFilterFormula(): string { + return BACKDROP_FILTER_VARS.map((v) => `var(${v},)`).join(' ') +} + +export interface CompositeSpec { + /** Variables CSS que CE préfixe pose (2 pour `scale` bare : scale-x ET scale-y). */ + cssVars: string[] + /** Enrobe la valeur dans la fonction attendue (`skewX(3deg)`), ou identité si la formule + * partagée utilise déjà la valeur brute (`scale`/`translate`, propriétés natives). */ + wrap: (value: string) => string + property: string + formula: () => string +} + +export const COMPOSITE_BY_PREFIX: Record = { + scale: { cssVars: ['--tw-scale-x', '--tw-scale-y'], wrap: (v) => v, property: 'scale', formula: () => 'var(--tw-scale-x, 1) var(--tw-scale-y, 1)' }, + 'scale-x': { cssVars: ['--tw-scale-x'], wrap: (v) => v, property: 'scale', formula: () => 'var(--tw-scale-x, 1) var(--tw-scale-y, 1)' }, + 'scale-y': { cssVars: ['--tw-scale-y'], wrap: (v) => v, property: 'scale', formula: () => 'var(--tw-scale-x, 1) var(--tw-scale-y, 1)' }, + 'translate-x': { cssVars: ['--tw-translate-x'], wrap: (v) => v, property: 'translate', formula: () => 'var(--tw-translate-x, 0) var(--tw-translate-y, 0)' }, + 'translate-y': { cssVars: ['--tw-translate-y'], wrap: (v) => v, property: 'translate', formula: () => 'var(--tw-translate-x, 0) var(--tw-translate-y, 0)' }, + 'skew-x': { cssVars: ['--tw-skew-x'], wrap: (v) => `skewX(${v})`, property: 'transform', formula: transformFormula }, + 'skew-y': { cssVars: ['--tw-skew-y'], wrap: (v) => `skewY(${v})`, property: 'transform', formula: transformFormula }, + + blur: { cssVars: ['--tw-blur'], wrap: (v) => (v ? `blur(${v})` : ''), property: 'filter', formula: filterFormula }, + brightness: { cssVars: ['--tw-brightness'], wrap: (v) => `brightness(${v})`, property: 'filter', formula: filterFormula }, + contrast: { cssVars: ['--tw-contrast'], wrap: (v) => `contrast(${v})`, property: 'filter', formula: filterFormula }, + grayscale: { cssVars: ['--tw-grayscale'], wrap: (v) => `grayscale(${v})`, property: 'filter', formula: filterFormula }, + 'hue-rotate': { cssVars: ['--tw-hue-rotate'], wrap: (v) => `hue-rotate(${v})`, property: 'filter', formula: filterFormula }, + invert: { cssVars: ['--tw-invert'], wrap: (v) => `invert(${v})`, property: 'filter', formula: filterFormula }, + saturate: { cssVars: ['--tw-saturate'], wrap: (v) => `saturate(${v})`, property: 'filter', formula: filterFormula }, + sepia: { cssVars: ['--tw-sepia'], wrap: (v) => `sepia(${v})`, property: 'filter', formula: filterFormula }, + + 'backdrop-blur': { cssVars: ['--tw-backdrop-blur'], wrap: (v) => (v ? `blur(${v})` : ''), property: 'backdrop-filter', formula: backdropFilterFormula }, + 'backdrop-brightness': { cssVars: ['--tw-backdrop-brightness'], wrap: (v) => `brightness(${v})`, property: 'backdrop-filter', formula: backdropFilterFormula }, + 'backdrop-contrast': { cssVars: ['--tw-backdrop-contrast'], wrap: (v) => `contrast(${v})`, property: 'backdrop-filter', formula: backdropFilterFormula }, + 'backdrop-grayscale': { cssVars: ['--tw-backdrop-grayscale'], wrap: (v) => `grayscale(${v})`, property: 'backdrop-filter', formula: backdropFilterFormula }, + 'backdrop-hue-rotate': { cssVars: ['--tw-backdrop-hue-rotate'], wrap: (v) => `hue-rotate(${v})`, property: 'backdrop-filter', formula: backdropFilterFormula }, + 'backdrop-invert': { cssVars: ['--tw-backdrop-invert'], wrap: (v) => `invert(${v})`, property: 'backdrop-filter', formula: backdropFilterFormula }, + 'backdrop-opacity': { cssVars: ['--tw-backdrop-opacity'], wrap: (v) => `opacity(${v})`, property: 'backdrop-filter', formula: backdropFilterFormula }, + 'backdrop-saturate': { cssVars: ['--tw-backdrop-saturate'], wrap: (v) => `saturate(${v})`, property: 'backdrop-filter', formula: backdropFilterFormula }, + 'backdrop-sepia': { cssVars: ['--tw-backdrop-sepia'], wrap: (v) => `sepia(${v})`, property: 'backdrop-filter', formula: backdropFilterFormula }, +} + +export function compositeDeclarations(spec: CompositeSpec, value: string): string[] { + const varDecls = spec.cssVars.map((v) => `${v}: ${spec.wrap(value)}`) + // -webkit-backdrop-filter en plus (vérifié dans la sortie réelle v4) : coût nul, meilleure fidélité. + const propDecls = spec.property === 'backdrop-filter' ? [`-webkit-backdrop-filter: ${spec.formula()}`, `${spec.property}: ${spec.formula()}`] : [`${spec.property}: ${spec.formula()}`] + return [...varDecls, ...propDecls] +} diff --git a/src/core/contrast.ts b/src/core/contrast.ts index 6a95e26..9d982d7 100644 --- a/src/core/contrast.ts +++ b/src/core/contrast.ts @@ -55,10 +55,35 @@ function ensureCanvasCtx(): CanvasRenderingContext2D | null { return canvasCtx } -/** Convertit n'importe quelle syntaxe de couleur CSS valide en RGB 0-255 concrets. */ -export function cssColorToRgb(css: string): Rgb | null { +export interface Rgba extends Rgb { + /** 0 (transparent) à 1 (opaque). */ + a: number +} + +const WHITE: Rgb = { r: 255, g: 255, b: 255 } + +// Résultats déjà convertis : une liste de couleurs dans un popover recalcule le contraste de +// chaque ligne à chaque rendu (survol), soit des centaines de `getImageData` sans ce cache. Les +// couleurs rencontrées sont en nombre limité (palette + couleurs de la page) ; le plafond ne +// sert que de garde-fou. +const MAX_CACHED_COLORS = 2000 +const rgbaCache = new Map() + +/** Convertit n'importe quelle syntaxe de couleur CSS valide en RGBA concrets (canaux 0-255, + * alpha 0-1). `getImageData` renvoie des valeurs non prémultipliées : un pixel translucide garde + * sa teinte (perte de précision négligeable sauf alpha très faible). */ +export function cssColorToRgba(css: string): Rgba | null { + const cached = rgbaCache.get(css) + if (cached !== undefined) return cached const probe = ensureProbe() if (!probe) return null + if (rgbaCache.size >= MAX_CACHED_COLORS) rgbaCache.clear() + const result = convertWithCanvas(probe, css) + rgbaCache.set(css, result) + return result +} + +function convertWithCanvas(probe: HTMLElement, css: string): Rgba | null { probe.style.color = '' probe.style.color = css if (!probe.style.color) return null // valeur invalide, rejetée par le CSSOM @@ -68,8 +93,27 @@ export function cssColorToRgb(css: string): Rgb | null { ctx.clearRect(0, 0, 1, 1) ctx.fillStyle = probe.style.color ctx.fillRect(0, 0, 1, 1) - const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data - return { r, g, b } + const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data + return { r, g, b, a: a / 255 } +} + +/** Composition « source-over » d'une couleur translucide sur un fond opaque (espace sRGB, comme + * le rendu par défaut des navigateurs). */ +export function compositeOver(top: Rgba, bottom: Rgb): Rgb { + const mix = (t: number, b: number) => Math.round(t * top.a + b * (1 - top.a)) + return { r: mix(top.r, bottom.r), g: mix(top.g, bottom.g), b: mix(top.b, bottom.b) } +} + +/** Aplatit une pile de fonds (le plus proche du texte en premier) sur le blanc par défaut du + * canevas. `null` si une couche est illisible : mieux vaut pas de verdict qu'un faux. */ +export function flattenBackgrounds(layers: string[]): Rgb | null { + let result = WHITE + for (let i = layers.length - 1; i >= 0; i--) { + const layer = cssColorToRgba(layers[i]) + if (!layer) return null + result = compositeOver(layer, result) + } + return result } export interface WcagRating { @@ -81,13 +125,15 @@ export interface WcagRating { } /** Note un couple texte/fond selon les seuils WCAG 2.1 (AA : 4.5:1 texte normal / 3:1 texte - * large ; AAA : 7:1 / 4.5:1). `null` si une des deux couleurs n'a pas pu être analysée - * (ex. `transparent` sans fallback). */ -export function rateContrast(foreground: string, background: string, fontSizePx: number, bold: boolean): WcagRating | null { - const fg = cssColorToRgb(foreground) - const bg = cssColorToRgb(background) - if (!fg || !bg) return null - const ratio = contrastRatio(fg, bg) + * large ; AAA : 7:1 / 4.5:1). `backgrounds` = pile de fonds, le plus proche du texte en premier : + * les fonds translucides sont composés entre eux puis le texte (lui aussi éventuellement + * translucide) sur le résultat, pour noter les couleurs réellement affichées. `null` si une + * des couleurs n'a pas pu être analysée. */ +export function rateContrast(foreground: string, backgrounds: string[], fontSizePx: number, bold: boolean): WcagRating | null { + const fgRgba = cssColorToRgba(foreground) + const bg = flattenBackgrounds(backgrounds) + if (!fgRgba || !bg) return null + const ratio = contrastRatio(compositeOver(fgRgba, bg), bg) const isLargeText = fontSizePx >= 24 || (fontSizePx >= 18.66 && bold) return { ratio, diff --git a/src/core/css-guard.ts b/src/core/css-guard.ts new file mode 100644 index 0000000..99e7d40 --- /dev/null +++ b/src/core/css-guard.ts @@ -0,0 +1,68 @@ +// Garde-fous sur le CSS synthétisé par live-style.ts : valeurs arbitraires et contenu des +// variants `has-[…]`/`data-[…]` viennent de la saisie de l'utilisateur et finissent dans le +// texte d'un ` @@ -24,9 +29,16 @@ + (collectPrefixCandidates / pickSitePrefix) — régression couverte par un test dédié. -->
après A
après B
après C
+ + +
+

Texte translucide.

+
+

Texte sur dégradé.

diff --git a/tests/e2e/fixtures/pages/themed-prefixed.html b/tests/e2e/fixtures/pages/themed-prefixed.html index 4b359ea..5a9f1eb 100644 --- a/tests/e2e/fixtures/pages/themed-prefixed.html +++ b/tests/e2e/fixtures/pages/themed-prefixed.html @@ -23,7 +23,7 @@ - +
Préfixe tw A
Préfixe tw B
Préfixe tw C
diff --git a/tests/e2e/ux-editing.spec.ts b/tests/e2e/ux-editing.spec.ts new file mode 100644 index 0000000..66bb324 --- /dev/null +++ b/tests/e2e/ux-editing.spec.ts @@ -0,0 +1,111 @@ +import { expect, test } from './fixtures/extension.js' +import type { Page } from '@playwright/test' + +/** Applique une classe via la recherche globale, puis revient à la vue normale. */ +async function applyFromSearch(devpanel: Page, className: string) { + const searchInput = devpanel.locator('#devwind-search-input') + await searchInput.fill(className) + await devpanel.locator('.devwind-value__label', { hasText: className }).first().click() + await searchInput.fill('') +} + +test.describe('Annuler / rétablir', () => { + test('Ctrl+Z annule la dernière modification, Ctrl+Maj+Z la rétablit', async ({ openFixture }) => { + const { page, devpanel } = await openFixture('basic.html') + const btn = page.locator('#target-btn') + await page.click('#target-btn') + await applyFromSearch(devpanel, 'bg-black') + await expect(btn).toHaveClass(/bg-black/) + + // Hors d'un champ texte (qui garde son propre Ctrl+Z). + await devpanel.locator('.devwind-panel__title').click() + await devpanel.keyboard.press('Control+z') + await expect(btn).toHaveClass(/bg-red-500/) + await expect(btn).not.toHaveClass(/bg-black/) + await expect(devpanel.locator('.devwind-chip__base', { hasText: 'bg-red-500' })).toBeVisible() + + await devpanel.keyboard.press('Control+Shift+z') + await expect(btn).toHaveClass(/bg-black/) + }) + + test("le bouton d'une entrée d'historique l'annule ; un conflit est refusé avec un message", async ({ openFixture }) => { + const { page, devpanel } = await openFixture('basic.html') + const btn = page.locator('#target-btn') + await page.click('#target-btn') + await applyFromSearch(devpanel, 'bg-black') + await applyFromSearch(devpanel, 'bg-white') + + await devpanel.locator('.devwind-changelog-btn').click() + // Entrée la plus ancienne (bg-red-500 → bg-black) : bg-black a été remplacé depuis. + await devpanel.locator('.devwind-changelog__entry').last().getByRole('button', { name: /^Annuler/ }).click() + await expect(devpanel.locator('.devwind-notice')).toContainText('modifiées depuis') + await expect(btn).toHaveClass(/bg-white/) + + await devpanel.locator('.devwind-changelog__entry').first().getByRole('button', { name: /^Annuler/ }).click() + await expect(btn).toHaveClass(/bg-black/) + await expect(devpanel.locator('.devwind-changelog__entry--undone')).toHaveCount(1) + }) +}) + +test('aperçu au survol dans le popover, sans toucher à l’historique', async ({ openFixture }) => { + const { page, devpanel } = await openFixture('basic.html') + const btn = page.locator('#target-btn') + await page.click('#target-btn') + + await devpanel.locator('.devwind-category-nav__tab', { hasText: 'Couleurs' }).click() + await devpanel.getByRole('button', { name: /^Fond :/ }).click() + await devpanel.locator('.devwind-vpl__row', { hasText: /^black/ }).first().hover() + await expect(btn).toHaveClass(/bg-black/) + await expect(btn).toHaveCSS('background-color', 'rgb(0, 0, 0)') + await expect(devpanel.locator('.devwind-changelog-btn__count')).toHaveCount(0) + + // Souris hors de la liste : retour à l'état réel. + await devpanel.locator('.devwind-panel__title').hover() + await expect(btn).toHaveClass(/bg-red-500/) + await expect(btn).not.toHaveClass(/bg-black/) +}) + +test('export des classes finales de tous les éléments modifiés, avec sélecteurs uniques', async ({ openFixture }) => { + const { page, devpanel } = await openFixture('basic.html') + await devpanel.evaluate(() => { + const copied: string[] = [] + ;(window as unknown as { __copied: string[] }).__copied = copied + navigator.clipboard.writeText = async (text: string) => { + copied.push(text) + } + }) + + await page.click('#target-btn') + await applyFromSearch(devpanel, 'bg-black') + await page.click('#target-p') + await applyFromSearch(devpanel, 'text-red-500') + + await devpanel.locator('.devwind-changelog-btn').click() + await devpanel.getByRole('button', { name: 'Classes finales' }).click() + await expect.poll(() => devpanel.evaluate(() => (window as unknown as { __copied: string[] }).__copied.at(-1) ?? '')).toContain( + 'button#target-btn: ', + ) + const text = await devpanel.evaluate(() => (window as unknown as { __copied: string[] }).__copied.at(-1) ?? '') + expect(text).toContain('bg-black') + expect(text).toContain('p#target-p: text-left text-red-500') +}) + +test('les breakpoints de la toolbar suivent les media queries du site', async ({ openFixture }) => { + const { page, devpanel } = await openFixture('basic.html') + await page.click('#target-btn') + await expect(devpanel.locator('.devwind-variant-pill', { hasText: /^sm$/ })).toHaveAttribute('title', 'À partir de 30rem') +}) + +test('après rechargement de la page, « Reconnecter » rétablit le panneau', async ({ openFixture }) => { + const { page, devpanel } = await openFixture('basic.html') + await page.click('#target-btn') + await page.reload() + + const reconnect = devpanel.getByRole('button', { name: 'Reconnecter' }) + await expect(reconnect).toBeVisible() + await reconnect.click() + await expect(devpanel.locator('.devwind-hint')).toBeVisible() + + await page.click('#target-btn') + await expect(devpanel.locator('.devwind-panel__count')).toContainText('button') +}) diff --git a/tests/unit/arbitrary-value.test.ts b/tests/unit/arbitrary-value.test.ts new file mode 100644 index 0000000..0045138 --- /dev/null +++ b/tests/unit/arbitrary-value.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { decodeArbitraryValue, unquote } from '../../src/core/arbitrary-value' + +describe('decodeArbitraryValue', () => { + it('convertit `_` en espace', () => { + expect(decodeArbitraryValue('1fr_2fr')).toBe('1fr 2fr') + expect(decodeArbitraryValue('rgb(0_0_0)')).toBe('rgb(0 0 0)') + }) + + it('garde un vrai underscore échappé', () => { + expect(decodeArbitraryValue('a\\_b')).toBe('a_b') + }) + + it('ne touche pas aux underscores d’une url()', () => { + expect(decodeArbitraryValue('url(/img/my_file.png)')).toBe('url(/img/my_file.png)') + expect(decodeArbitraryValue('url(/a_b.png)_center')).toBe('url(/a_b.png) center') + }) +}) + +describe('unquote', () => { + it('retire une paire de guillemets englobante', () => { + expect(unquote('"open"')).toBe('open') + expect(unquote("'open'")).toBe('open') + }) + + it('laisse le reste intact', () => { + expect(unquote('open')).toBe('open') + expect(unquote('"open\'')).toBe('"open\'') + }) +}) diff --git a/tests/unit/breakpoints.test.ts b/tests/unit/breakpoints.test.ts new file mode 100644 index 0000000..510ab08 --- /dev/null +++ b/tests/unit/breakpoints.test.ts @@ -0,0 +1,65 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from 'vitest' +import { breakpointsFromThemeVariables, breakpointsFromVotes, sortedBreakpointNames } from '../../src/core/breakpoints' +import { collectBreakpointVotes } from '../../src/core/breakpoint-scanner' +import type { BreakpointVote } from '../../src/types' + +function votesFor(css: string): BreakpointVote[] { + const sheet = new CSSStyleSheet() + sheet.replaceSync(css) + const votes = new Map() + collectBreakpointVotes(sheet.cssRules, votes) + return Array.from(votes.values()) +} + +describe('collectBreakpointVotes', () => { + it('lit les media queries v4 et v3 autour des classes à variant', () => { + const votes = votesFor(` + @media (width >= 40rem) { .sm\\:flex { display: flex } } + @media (width < 64rem) { .max-lg\\:hidden { display: none } } + @media (min-width: 768px) { .md\\:block { display: block } } + @media (min-width: 768px) { .container { max-width: 768px } } + @media print { .print\\:hidden { display: none } } + `) + expect(votes).toEqual( + expect.arrayContaining([ + ['sm', '', 'min', '40rem', 1], + ['max-lg', '', 'max', '64rem', 1], + ['md', '', 'min', '768px', 1], + ]), + ) + expect(votes).toHaveLength(3) + }) +}) + +describe('breakpointsFromVotes', () => { + it('déduit les noms, retire `max-` et le préfixe de site, garde le seuil majoritaire', () => { + const votes: BreakpointVote[] = [ + ['sm', '', 'min', '30rem', 5], + ['sm', '', 'min', '40rem', 1], + ['max-3xl', '', 'max', '120rem', 2], + ['tw', 'md', 'min', '50rem', 3], + ['hover', '', 'max', '10rem', 1], + ['min-[600px]', '', 'min', '600px', 1], + ] + expect(breakpointsFromVotes(votes, 'tw')).toEqual({ sm: '30rem', '3xl': '120rem', md: '50rem' }) + }) +}) + +describe('breakpointsFromThemeVariables', () => { + it('ne garde que les --breakpoint-* (préfixés si besoin) aux longueurs valides', () => { + const vars = [ + { name: '--tw-breakpoint-xs', value: '20rem' }, + { name: '--breakpoint-sm', value: '40rem' }, + { name: '--tw-breakpoint-bad', value: 'calc(1px)' }, + ] + expect(breakpointsFromThemeVariables(vars, 'tw')).toEqual({ xs: '20rem' }) + expect(breakpointsFromThemeVariables(vars, null)).toEqual({ sm: '40rem' }) + }) +}) + +describe('sortedBreakpointNames', () => { + it('trie par seuil, unités mélangées', () => { + expect(sortedBreakpointNames({ lg: '64rem', xs: '320px', md: '48rem' })).toEqual(['xs', 'md', 'lg']) + }) +}) diff --git a/tests/unit/change-log.test.ts b/tests/unit/change-log.test.ts new file mode 100644 index 0000000..aca364b --- /dev/null +++ b/tests/unit/change-log.test.ts @@ -0,0 +1,71 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it } from 'vitest' +import { applyClassEdit } from '../../src/core/class-diff' +import { clearChangeLog, finalClasses, getChangeLog, logChange, revertEntry } from '../../src/content/change-log' +import { pickRedoTarget, pickUndoTarget } from '../../src/devpanel/store/history' + +afterEach(() => { + clearChangeLog() + document.body.innerHTML = '' +}) + +function setup() { + document.body.innerHTML = '' + return document.getElementById('b')! +} + +const noRules = new Map() + +describe('historique : annuler / rétablir', () => { + it('annule par diff inverse en gardant les autres modifications', () => { + const el = setup() + const first = logChange(el, applyClassEdit(el, { remove: ['bg-red-500'], add: 'bg-black' }))! + logChange(el, applyClassEdit(el, { remove: ['p-4'], add: 'p-8' })) + + const outcome = revertEntry(first.id, true, noRules) + expect(outcome?.ok).toBe(true) + expect(el.getAttribute('class')!.split(' ').sort()).toEqual(['bg-red-500', 'p-8']) + + const redo = revertEntry(first.id, false, noRules) + expect(redo?.ok).toBe(true) + expect(el.getAttribute('class')!.split(' ').sort()).toEqual(['bg-black', 'p-8']) + }) + + it('refuse si les classes ont été modifiées depuis, ou si l’élément a quitté la page', () => { + const el = setup() + const first = logChange(el, applyClassEdit(el, { remove: ['bg-red-500'], add: 'bg-black' }))! + logChange(el, applyClassEdit(el, { remove: ['bg-black'], add: 'bg-blue-500' })) + expect(revertEntry(first.id, true, noRules)).toEqual({ ok: false, reason: 'modified' }) + + const last = getChangeLog()[1] + el.remove() + expect(revertEntry(last.id, true, noRules)).toEqual({ ok: false, reason: 'detached' }) + }) + + it('le clavier vise la dernière modification, puis la dernière annulation tant que rien de neuf', () => { + const el = setup() + logChange(el, applyClassEdit(el, { remove: ['bg-red-500'], add: 'bg-black' })) + const second = logChange(el, applyClassEdit(el, { remove: ['p-4'], add: 'p-8' }))! + expect(pickUndoTarget(getChangeLog())?.id).toBe(second.id) + expect(pickRedoTarget(getChangeLog())).toBeNull() + + revertEntry(second.id, true, noRules) + expect(pickRedoTarget(getChangeLog())?.id).toBe(second.id) + + // Une nouvelle modification ferme la pile de rétablissement. + logChange(el, applyClassEdit(el, { remove: [], add: 'flex' })) + expect(pickRedoTarget(getChangeLog())).toBeNull() + }) +}) + +describe('finalClasses', () => { + it('exporte les éléments modifiés, sauf ceux revenus à leur état d’origine', () => { + document.body.innerHTML = '

' + const a = document.getElementById('a')! + const b = document.getElementById('b')! + logChange(a, applyClassEdit(a, { remove: ['x'], add: 'z' })) + const onB = logChange(b, applyClassEdit(b, { remove: [], add: 'w' }))! + revertEntry(onB.id, true, noRules) + expect(finalClasses()).toEqual([{ selector: 'p#a', classes: ['z'] }]) + }) +}) diff --git a/tests/unit/class-diff.test.ts b/tests/unit/class-diff.test.ts new file mode 100644 index 0000000..6757121 --- /dev/null +++ b/tests/unit/class-diff.test.ts @@ -0,0 +1,71 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from 'vitest' +import { addRawClass, applyClassEdit, removeRawClass } from '../../src/core/class-diff' +import { planClassEdit } from '../../src/core/class-edit-plan' + +describe('planClassEdit', () => { + it('retire la classe du même slot', () => { + const edit = planClassEdit(['flex', 'bg-red-500', 'p-4'], { taxonomyId: 'backgroundColor', prefix: 'bg', variants: [], newBase: 'bg-blue-500' }) + expect(edit).toEqual({ remove: ['bg-red-500'], add: 'bg-blue-500' }) + }) + + it('ne touche pas aux autres côtés d’une entrée multi-préfixes', () => { + const edit = planClassEdit(['px-3', 'pt-2'], { taxonomyId: 'padding', prefix: 'pt', variants: [], newBase: 'pt-8' }) + expect(edit).toEqual({ remove: ['pt-2'], add: 'pt-8' }) + }) + + it('isole chaque contexte de variant (ordre des variants indifférent)', () => { + const edit = planClassEdit(['bg-red-500', 'hover:md:bg-red-700'], { taxonomyId: 'backgroundColor', prefix: 'bg', variants: ['md', 'hover'], newBase: 'bg-green-500' }) + expect(edit).toEqual({ remove: ['hover:md:bg-red-700'], add: 'md:hover:bg-green-500' }) + }) + + it('vide le slot quand newBase est null', () => { + const edit = planClassEdit(['flex', 'bg-red-500'], { taxonomyId: 'backgroundColor', prefix: 'bg', variants: [], newBase: null }) + expect(edit).toEqual({ remove: ['bg-red-500'], add: null }) + }) + + it('dédoublonne les conflits déjà présents', () => { + const edit = planClassEdit(['bg-red-500', 'bg-blue-500'], { taxonomyId: 'backgroundColor', prefix: 'bg', variants: [], newBase: 'bg-green-500' }) + expect(edit.remove).toEqual(['bg-red-500', 'bg-blue-500']) + }) +}) + +function el(classes: string): Element { + const div = document.createElement('div') + div.setAttribute('class', classes) + return div +} + +describe('applyClassEdit', () => { + it('retire puis ajoute', () => { + const e = el('flex bg-red-500 p-4') + expect(applyClassEdit(e, { remove: ['bg-red-500'], add: 'bg-blue-500' })).toEqual({ before: 'flex bg-red-500 p-4', after: 'flex p-4 bg-blue-500' }) + }) + + it('ignore une classe à retirer absente de l’élément', () => { + const e = el('flex') + applyClassEdit(e, { remove: ['bg-red-500'], add: null }) + expect(e.getAttribute('class')).toBe('flex') + }) + + it('fonctionne sur un élément SVG (className = SVGAnimatedString)', () => { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') + svg.setAttribute('class', 'w-4 h-4') + applyClassEdit(svg, { remove: ['w-4'], add: 'w-6' }) + expect(svg.getAttribute('class')).toBe('h-4 w-6') + }) +}) + +describe('removeRawClass / addRawClass', () => { + it('retire une classe précise', () => { + const e = el('flex custom-a p-4') + expect(removeRawClass(e, 'custom-a').after).toBe('flex p-4') + }) + + it('ajoute une classe sans doublon', () => { + const e = el('flex') + addRawClass(e, 'custom-a') + addRawClass(e, 'custom-a') + expect(e.getAttribute('class')).toBe('flex custom-a') + }) +}) diff --git a/tests/unit/class-parser.test.ts b/tests/unit/class-parser.test.ts new file mode 100644 index 0000000..f93ff5b --- /dev/null +++ b/tests/unit/class-parser.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { matchTaxonomy } from '../../src/core/class-parser' + +function match(base: string) { + const m = matchTaxonomy(base) + return m && { id: m.entry.id, prefix: m.prefix, suffix: m.suffix, isArbitrary: m.isArbitrary, isNegative: m.isNegative } +} + +describe('matchTaxonomy', () => { + it('reconnaît les classes static (avec ou sans préfixe)', () => { + expect(match('flex')).toMatchObject({ id: 'display', prefix: '', suffix: 'flex' }) + expect(match('text-left')).toMatchObject({ id: 'textAlign', prefix: 'text', suffix: 'left' }) + }) + + it('désambiguïse un préfixe partagé grâce au dataset', () => { + expect(match('text-xl')).toMatchObject({ id: 'fontSize', suffix: 'xl' }) + expect(match('text-red-500')).toMatchObject({ id: 'textColor', suffix: 'red-500' }) + }) + + it('distingue les côtés d’une entrée multi-préfixes', () => { + expect(match('pt-8')).toMatchObject({ id: 'padding', prefix: 'pt', suffix: '8' }) + expect(match('px-3')).toMatchObject({ id: 'padding', prefix: 'px', suffix: '3' }) + }) + + it('gère les valeurs négatives des entrées qui les supportent', () => { + expect(match('-mt-4')).toMatchObject({ id: 'margin', prefix: 'mt', isNegative: true }) + expect(match('-pt-4')).toBeNull() + }) + + it('gère la forme nue (clé DEFAULT)', () => { + expect(match('rounded')).toMatchObject({ id: 'borderRadius', prefix: 'rounded', suffix: '' }) + }) + + it('gère le modificateur d’opacité', () => { + expect(match('bg-red-500/80')).toMatchObject({ id: 'backgroundColor', suffix: 'red-500/80', isArbitrary: false }) + }) + + it('gère les valeurs arbitraires, avec ou sans opacité', () => { + expect(match('bg-[#ff0000]')).toMatchObject({ id: 'backgroundColor', suffix: '#ff0000', isArbitrary: true }) + expect(match('bg-[#ff0000]/50')).toMatchObject({ id: 'backgroundColor', suffix: '#ff0000/50', isArbitrary: true }) + }) + + it('ne confond pas une classe custom qui partage un préfixe', () => { + expect(match('my-custom-btn')).toBeNull() + expect(match('btn-primary')).toBeNull() + }) +}) diff --git a/tests/unit/contrast.test.ts b/tests/unit/contrast.test.ts new file mode 100644 index 0000000..1547791 --- /dev/null +++ b/tests/unit/contrast.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { compositeOver, contrastRatio, relativeLuminance } from '../../src/core/contrast' +import { colorAlpha } from '../../src/core/color-alpha' + +const BLACK = { r: 0, g: 0, b: 0 } +const WHITE = { r: 255, g: 255, b: 255 } + +describe('contraste WCAG', () => { + it('luminance relative', () => { + expect(relativeLuminance(BLACK)).toBe(0) + expect(relativeLuminance(WHITE)).toBeCloseTo(1) + }) + + it('ratio de contraste, symétrique', () => { + expect(contrastRatio(BLACK, WHITE)).toBeCloseTo(21) + expect(contrastRatio(WHITE, BLACK)).toBeCloseTo(21) + expect(contrastRatio(WHITE, WHITE)).toBe(1) + // #767676 sur blanc : seuil AA classique (≈ 4.54:1) + expect(contrastRatio({ r: 118, g: 118, b: 118 }, WHITE)).toBeCloseTo(4.54, 2) + }) +}) + +describe('compositeOver', () => { + it('opaque : la couleur du dessus', () => { + expect(compositeOver({ ...BLACK, a: 1 }, WHITE)).toEqual(BLACK) + }) + + it('transparent : le fond', () => { + expect(compositeOver({ ...BLACK, a: 0 }, WHITE)).toEqual(WHITE) + }) + + it('mi-transparent : moyenne', () => { + expect(compositeOver({ ...BLACK, a: 0.5 }, WHITE)).toEqual({ r: 128, g: 128, b: 128 }) + }) +}) + +describe('colorAlpha', () => { + it('transparent et couleurs opaques', () => { + expect(colorAlpha('transparent')).toBe(0) + expect(colorAlpha('rgb(0, 0, 0)')).toBe(1) + expect(colorAlpha('oklch(0.6 0.2 30)')).toBe(1) + }) + + it('syntaxe à virgules', () => { + expect(colorAlpha('rgba(0, 0, 0, 0.5)')).toBe(0.5) + expect(colorAlpha('rgba(0, 0, 0, 0)')).toBe(0) + }) + + it('syntaxe moderne avec `/`', () => { + expect(colorAlpha('oklch(0.6 0.2 30 / 0.25)')).toBe(0.25) + expect(colorAlpha('color(srgb 1 0 0 / 50%)')).toBe(0.5) + }) + + it('borne entre 0 et 1', () => { + expect(colorAlpha('rgba(0, 0, 0, 2)')).toBe(1) + }) +}) diff --git a/tests/unit/css-scanner.test.ts b/tests/unit/css-scanner.test.ts new file mode 100644 index 0000000..643fa4a --- /dev/null +++ b/tests/unit/css-scanner.test.ts @@ -0,0 +1,52 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { watchForStylesheetChanges } from '../../src/core/css-scanner' + +afterEach(() => { + vi.useRealTimers() + document.head.innerHTML = '' +}) + +/** Laisse passer le callback du MutationObserver (microtâche) puis le debounce. */ +async function flush() { + await Promise.resolve() + vi.advanceTimersByTime(400) +} + +describe('watchForStylesheetChanges', () => { + it('ignore la feuille injectée par DevWind (ajout et contenu)', async () => { + vi.useFakeTimers() + const onChange = vi.fn() + const stop = watchForStylesheetChanges(onChange, document, 'devwind-live-styles') + + const own = document.createElement('style') + own.id = 'devwind-live-styles' + document.head.appendChild(own) + own.appendChild(document.createTextNode('.a { color: red; }')) + await flush() + expect(onChange).not.toHaveBeenCalled() + + stop() + }) + + it('réagit à une feuille du site ajoutée, retirée ou dont le contenu change', async () => { + vi.useFakeTimers() + const onChange = vi.fn() + const stop = watchForStylesheetChanges(onChange, document, 'devwind-live-styles') + + const style = document.createElement('style') + document.head.appendChild(style) + await flush() + expect(onChange).toHaveBeenCalledTimes(1) + + style.textContent = '.b { color: blue; }' + await flush() + expect(onChange).toHaveBeenCalledTimes(2) + + style.remove() + await flush() + expect(onChange).toHaveBeenCalledTimes(3) + + stop() + }) +}) diff --git a/tests/unit/guards.test.ts b/tests/unit/guards.test.ts new file mode 100644 index 0000000..7d1f788 --- /dev/null +++ b/tests/unit/guards.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import { isBalancedSelectorFragment } from '../../src/core/css-guard' +import { isPickerMessage, isSyncFromContent, isSyncFromPanel } from '../../src/core/message-guards' + +describe('isBalancedSelectorFragment', () => { + it('accepte un fragment bien formé', () => { + expect(isBalancedSelectorFragment('> img')).toBe(true) + expect(isBalancedSelectorFragment('[data-x="a)b"]')).toBe(true) + expect(isBalancedSelectorFragment('a:not(.b)')).toBe(true) + expect(isBalancedSelectorFragment('a\\)')).toBe(true) + }) + + it('refuse ce qui sortirait du :has() ou casserait la règle', () => { + expect(isBalancedSelectorFragment('a),body *,x:has(b')).toBe(false) + expect(isBalancedSelectorFragment('a{}')).toBe(false) + expect(isBalancedSelectorFragment('a;b')).toBe(false) + expect(isBalancedSelectorFragment('[x="a]')).toBe(false) + expect(isBalancedSelectorFragment('(]')).toBe(false) + }) +}) + +describe('isSyncFromPanel', () => { + it('accepte les messages valides', () => { + const rule = { rules: ['.md\\:pt-4 { padding-top: 1rem !important; }'], dark: false } + expect(isSyncFromPanel({ type: 'APPLY_CHANGE', edit: { remove: ['pt-2'], add: 'md:pt-4', liveRule: rule } })).toBe(true) + expect(isSyncFromPanel({ type: 'APPLY_CHANGE', edit: { remove: ['pt-2'], add: null, liveRule: null } })).toBe(true) + expect(isSyncFromPanel({ type: 'SELECT_ANCESTOR', index: 0 })).toBe(true) + expect(isSyncFromPanel({ type: 'NAVIGATE', direction: 'parent' })).toBe(true) + expect(isSyncFromPanel({ type: 'RUN_CSS_SCAN' })).toBe(true) + expect(isSyncFromPanel({ type: 'RUN_THEME_SCAN', prefix: 'tw' })).toBe(true) + expect(isSyncFromPanel({ type: 'RUN_THEME_SCAN', prefix: null })).toBe(true) + }) + + it('refuse les messages mal formés', () => { + expect(isSyncFromPanel(null)).toBe(false) + expect(isSyncFromPanel({ type: 'INCONNU' })).toBe(false) + expect(isSyncFromPanel({ type: 'REMOVE_CLASS', rawClass: 'a b' })).toBe(false) + expect(isSyncFromPanel({ type: 'SELECT_ANCESTOR', index: -1 })).toBe(false) + expect(isSyncFromPanel({ type: 'SELECT_ANCESTOR', index: 1.5 })).toBe(false) + expect(isSyncFromPanel({ type: 'NAVIGATE', direction: 'up' })).toBe(false) + expect(isSyncFromPanel({ type: 'APPLY_CHANGE', edit: { remove: ['a b'], add: 'pt-4', liveRule: null } })).toBe(false) + expect(isSyncFromPanel({ type: 'APPLY_CHANGE', edit: { remove: [], add: 'pt-4', liveRule: { rules: ['a', 'b'], dark: false } } })).toBe(false) + expect(isSyncFromPanel({ type: 'RUN_THEME_SCAN' })).toBe(false) + expect(isSyncFromPanel({ type: 'RUN_THEME_SCAN', prefix: '--x' })).toBe(false) + expect(isSyncFromPanel({ type: 'REVERT_CHANGE', id: 1, undo: true, liveRules: [['a b', null]] })).toBe(false) + }) + + it('aperçu, annulation et export', () => { + expect(isSyncFromPanel({ type: 'PREVIEW_CHANGE', edit: { remove: [], add: 'pt-4', liveRule: null } })).toBe(true) + expect(isSyncFromPanel({ type: 'CANCEL_PREVIEW' })).toBe(true) + expect(isSyncFromPanel({ type: 'REVERT_CHANGE', id: 1, undo: false, liveRules: [['p-4', { rules: ['.p-4{}'], dark: false }]] })).toBe(true) + expect(isSyncFromPanel({ type: 'REQUEST_FINAL_CLASSES' })).toBe(true) + }) +}) + +describe('isPickerMessage', () => { + it('ping et activation uniquement', () => { + expect(isPickerMessage({ type: 'DEVWIND_PING' })).toBe(true) + expect(isPickerMessage({ type: 'DEVWIND_SET_ACTIVE', active: true })).toBe(true) + expect(isPickerMessage({ type: 'DEVWIND_SET_ACTIVE', active: 'oui' })).toBe(false) + expect(isPickerMessage({ type: 'DEVWIND_GET_STATE' })).toBe(false) + }) +}) + +describe('isSyncFromContent', () => { + const colors = { color: 'rgb(0, 0, 0)', backgroundColor: 'transparent', backdrop: ['rgb(255, 255, 255)'], approximate: false, fontSize: 16, bold: false } + + it('accepte une sélection valide', () => { + expect(isSyncFromContent({ type: 'ELEMENT_SELECTED', tagName: 'div', classes: ['p-4'], ancestors: [{ tagName: 'body', id: null, classes: [] }], colors })).toBe(true) + }) + + it('historique incrémental et candidats de préfixe', () => { + const entry = { id: 1, timestamp: 0, elementLabel: 'div', selector: 'body > div', added: ['p-4'], removed: [] } + expect(isSyncFromContent({ type: 'CHANGE_LOG_ENTRY', entry })).toBe(true) + expect(isSyncFromContent({ type: 'CHANGE_LOG_RESET', entries: [entry] })).toBe(true) + expect(isSyncFromContent({ type: 'CHANGE_LOG_ENTRY_UPDATED', entry: { ...entry, undoneSeq: 2 } })).toBe(true) + expect(isSyncFromContent({ type: 'CHANGE_LOG_ENTRY_UPDATED', entry: { ...entry, undoneSeq: 'oui' } })).toBe(false) + expect(isSyncFromContent({ type: 'REVERT_REJECTED', id: 1, reason: 'modified' })).toBe(true) + expect(isSyncFromContent({ type: 'REVERT_REJECTED', id: 1, reason: 'autre' })).toBe(false) + expect(isSyncFromContent({ type: 'FINAL_CLASSES', elements: [{ selector: 'p#a', classes: ['z'] }] })).toBe(true) + expect(isSyncFromContent({ type: 'CUSTOM_SCAN_RESULT', found: [], unscannable: [], breakpoints: [['sm', '', 'min', '40rem', 2]] })).toBe(true) + expect(isSyncFromContent({ type: 'CUSTOM_SCAN_RESULT', found: [], unscannable: [], breakpoints: [['sm', '', 'up', '40rem', 2]] })).toBe(false) + expect(isSyncFromContent({ type: 'PREFIX_CANDIDATES', candidates: [['tw', 'p-4', 3]] })).toBe(true) + expect(isSyncFromContent({ type: 'PREFIX_CANDIDATES', candidates: [['tw', 'p-4', 0]] })).toBe(false) + }) + + it('refuse des couleurs incomplètes ou une taille non finie', () => { + expect(isSyncFromContent({ type: 'CLASSES_UPDATED', classes: [], colors: { ...colors, backdrop: undefined } })).toBe(false) + expect(isSyncFromContent({ type: 'CLASSES_UPDATED', classes: [], colors: { ...colors, fontSize: Number.NaN } })).toBe(false) + }) +}) diff --git a/tests/unit/i18n.test.ts b/tests/unit/i18n.test.ts new file mode 100644 index 0000000..3b00661 --- /dev/null +++ b/tests/unit/i18n.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { translate } from '../../src/devpanel/i18n' +import { fr } from '../../src/devpanel/i18n/messages-fr' +import { en } from '../../src/devpanel/i18n/messages-en' + +describe('translate', () => { + it('remplace les paramètres et choisit le pluriel selon la langue', () => { + expect(translate('fr', 'panel.count', { tag: 'div', count: 1 })).toBe('
· 1 classe') + expect(translate('fr', 'panel.count', { tag: 'div', count: 0 })).toBe('
· 0 classe') + expect(translate('en', 'panel.count', { tag: 'div', count: 0 })).toBe('
· 0 classes') + expect(translate('en', 'chip.remove', { cls: 'p-4' })).toBe('Remove p-4') + }) + + it('les deux langues ont les mêmes paramètres pour chaque clé', () => { + const params = (m: unknown) => [...JSON.stringify(m).matchAll(/\{(\w+)\}/g)].map((x) => x[1]).sort() + for (const key of Object.keys(fr) as (keyof typeof fr)[]) { + expect(params(en[key]), key).toEqual(params(fr[key])) + } + }) +}) diff --git a/tests/unit/live-style.test.ts b/tests/unit/live-style.test.ts new file mode 100644 index 0000000..7cd5526 --- /dev/null +++ b/tests/unit/live-style.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { declarationsFor } from '../../src/core/live-style' + +describe('declarationsFor', () => { + it('spacing : multiplicateur de --spacing', () => { + expect(declarationsFor('p-4')).toEqual(['padding: calc(var(--spacing, 0.25rem) * 4)']) + expect(declarationsFor('-mt-2')).toEqual(['margin-top: calc(var(--spacing, 0.25rem) * -2)']) + }) + + it('couleur : variable de thème avec fallback', () => { + expect(declarationsFor('bg-red-500')).toEqual([expect.stringMatching(/^background-color: var\(--color-red-500, .+\)$/)]) + }) + + it('couleur avec opacité : color-mix', () => { + expect(declarationsFor('bg-red-500/50')?.[0]).toMatch(/^background-color: color-mix\(in srgb, var\(--color-red-500, .+\) 50%, transparent\)$/) + }) + + it('static : valeur traduite si besoin', () => { + expect(declarationsFor('flex')).toEqual(['display: flex']) + expect(declarationsFor('resize-x')).toEqual(['resize: horizontal']) + }) + + it('fontSize : line-height apparié', () => { + const decls = declarationsFor('text-sm') + expect(decls?.[0]).toMatch(/^font-size: var\(--text-sm, .+\)$/) + expect(decls?.[1]).toMatch(/^line-height: var\(--text-sm--line-height, .+\)$/) + }) + + it('préfixe de site appliqué aux variables de thème, y compris le line-height', () => { + expect(declarationsFor('bg-red-500', 'tw')?.[0]).toMatch(/var\(--tw-color-red-500, /) + expect(declarationsFor('p-4', 'tw')).toEqual(['padding: calc(var(--tw-spacing, 0.25rem) * 4)']) + expect(declarationsFor('text-sm', 'tw')?.[1]).toMatch(/var\(--tw-text-sm--line-height, /) + }) + + it('valeur arbitraire : `_` -> espace, sauf dans url()', () => { + expect(declarationsFor('w-[calc(100%_-_2rem)]')).toEqual(['width: calc(100% - 2rem)']) + expect(declarationsFor('bg-[rgb(0_0_0)]')).toEqual(['background-color: rgb(0 0 0)']) + }) + + it('propriétés composites : variable + formule partagée', () => { + const decls = declarationsFor('scale-105') + expect(decls).toContain('scale: var(--tw-scale-x, 1) var(--tw-scale-y, 1)') + expect(decls?.some((d) => d.startsWith('--tw-scale-x: '))).toBe(true) + }) + + it('classe inconnue : null', () => { + expect(declarationsFor('btn-primary')).toBeNull() + }) +}) diff --git a/tests/unit/search.test.ts b/tests/unit/search.test.ts new file mode 100644 index 0000000..158fed1 --- /dev/null +++ b/tests/unit/search.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { canonicalValue, editDistance, searchClasses } from '../../src/devpanel/search' + +const names = (query: string, limit?: number) => searchClasses(query, limit).items.map((c) => c.className) + +describe('searchClasses', () => { + it('classe exact > commence par > contient', () => { + const result = names('p-4', 500) + expect(result[0]).toBe('p-4') + // `p-40` commence par la requête, `-mx-p-4`... la contiendrait seulement : rangé avant. + expect(result.indexOf('p-40')).toBeGreaterThan(0) + expect(result.indexOf('p-40')).toBeLessThan(result.indexOf('-p-4') === -1 ? result.length : result.indexOf('-p-4')) + }) + + it('compte tous les résultats au-delà de la limite', () => { + const result = searchClasses('bg-', 60) + expect(result.items).toHaveLength(60) + expect(result.total).toBeGreaterThan(60) + expect(result.fuzzy).toBe(false) + }) + + it('recherche par valeur CSS (px converti en rem, s en ms)', () => { + expect(names('16px', 500)).toEqual(expect.arrayContaining(['p-4', 'text-base'])) + expect(names('0.15s', 500)).toContain('duration-150') + expect(names('50%', 500)).toContain('w-1/2') + }) + + it('suggestions tolérantes aux fautes quand rien ne correspond', () => { + const result = searchClasses('roudned-lg') + expect(result.fuzzy).toBe(true) + expect(result.items[0].className).toBe('rounded-lg') + // Faute dans un nom en cours de frappe : comparé au début des noms. + expect(searchClasses('opactiy').items.map((c) => c.className)).toEqual(expect.arrayContaining(['opacity-50'])) + }) + + it('requête vide : aucun résultat', () => { + expect(searchClasses(' ').total).toBe(0) + }) +}) + +describe('canonicalValue / editDistance', () => { + it('normalise les unités', () => { + expect(canonicalValue('16px')).toBe('1rem') + expect(canonicalValue('1rem')).toBe('1rem') + expect(canonicalValue('-8px')).toBe('-0.5rem') + expect(canonicalValue('red')).toBeNull() + }) + + it('compte une transposition comme une seule faute', () => { + expect(editDistance('itmes', 'items', 2)).toBe(1) + expect(editDistance('abc', 'xyz', 1)).toBe(2) + }) +}) diff --git a/tests/unit/site-prefix.test.ts b/tests/unit/site-prefix.test.ts new file mode 100644 index 0000000..b9bcad5 --- /dev/null +++ b/tests/unit/site-prefix.test.ts @@ -0,0 +1,31 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from 'vitest' +import { collectPrefixCandidates } from '../../src/core/prefix-candidates' +import { pickSitePrefix } from '../../src/core/site-prefix' + +function docWith(html: string): Document { + const doc = document.implementation.createHTMLDocument() + doc.body.innerHTML = html + return doc +} + +describe('collectPrefixCandidates', () => { + it('compte les variants de tête inconnus, ignore les variants Tailwind', () => { + const doc = docWith('
') + expect(collectPrefixCandidates(doc)).toEqual([ + ['tw', 'p-4', 2], + ['tw', 'p-2', 1], + ]) + }) +}) + +describe('pickSitePrefix', () => { + it('retient le préfixe dont les bases sont des classes Tailwind, à partir de 3 occurrences', () => { + expect(pickSitePrefix([['tw', 'p-4', 2], ['tw', 'bg-red-500', 1]])).toBe('tw') + expect(pickSitePrefix([['tw', 'p-4', 2]])).toBeNull() + }) + + it('ignore les bases non reconnues', () => { + expect(pickSitePrefix([['app', 'btn-primary', 10], ['tw', 'flex', 3]])).toBe('tw') + }) +}) diff --git a/tests/unit/split-variants.test.ts b/tests/unit/split-variants.test.ts new file mode 100644 index 0000000..03ed409 --- /dev/null +++ b/tests/unit/split-variants.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { joinVariants, splitVariants } from '../../src/core/split-variants' + +describe('splitVariants', () => { + it('sépare variants et base', () => { + expect(splitVariants('md:hover:bg-red-500')).toEqual({ raw: 'md:hover:bg-red-500', variants: ['md', 'hover'], base: 'bg-red-500' }) + }) + + it('classe sans variant', () => { + expect(splitVariants('flex')).toEqual({ raw: 'flex', variants: [], base: 'flex' }) + }) + + it('ignore les `:` entre crochets (valeur arbitraire)', () => { + expect(splitVariants('hover:bg-[url(https://x.dev:80/a.png)]')).toMatchObject({ + variants: ['hover'], + base: 'bg-[url(https://x.dev:80/a.png)]', + }) + }) + + it('ignore les `:` dans un variant arbitraire', () => { + expect(splitVariants('has-[a:hover]:p-4')).toMatchObject({ variants: ['has-[a:hover]'], base: 'p-4' }) + }) +}) + +describe('joinVariants', () => { + it('recompose la classe complète', () => { + expect(joinVariants(['md', 'hover'], 'bg-red-500')).toBe('md:hover:bg-red-500') + expect(joinVariants([], 'flex')).toBe('flex') + }) + + it('est l’inverse de splitVariants', () => { + const cls = 'dark:md:bg-[url(https://x.dev:80/a.png)]' + const { variants, base } = splitVariants(cls) + expect(joinVariants(variants, base)).toBe(cls) + }) +}) diff --git a/tests/unit/unique-selector.test.ts b/tests/unit/unique-selector.test.ts new file mode 100644 index 0000000..d231b71 --- /dev/null +++ b/tests/unit/unique-selector.test.ts @@ -0,0 +1,34 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it } from 'vitest' +import { shortElementLabel, uniqueSelector } from '../../src/core/unique-selector' + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('uniqueSelector', () => { + it('id unique : sélecteur direct', () => { + document.body.innerHTML = '
' + const el = document.getElementById('go')! + expect(uniqueSelector(el)).toBe('button#go') + expect(shortElementLabel(el)).toBe('button#go') + }) + + it('sans id : chemin positionnel le plus court qui ne désigne que lui', () => { + document.body.innerHTML = '
  • 1
  • 2
  • 3
  • 4
' + const second = document.querySelectorAll('li')[1] + const fourth = document.querySelectorAll('li')[3] + expect(uniqueSelector(second)).toBe('ul#a > li:nth-of-type(2)') + expect(document.querySelectorAll(uniqueSelector(fourth))).toHaveLength(1) + expect(document.querySelector(uniqueSelector(fourth))).toBe(fourth) + expect(shortElementLabel(fourth)).toBe('li:nth-of-type(2)') + }) + + it('id dupliqué : ignoré, jamais basé sur les classes', () => { + document.body.innerHTML = '
' + const second = document.querySelectorAll('div')[1] + const selector = uniqueSelector(second) + expect(selector).not.toContain('.a') + expect(document.querySelector(selector)).toBe(second) + }) +}) diff --git a/tests/unit/variant-plan.test.ts b/tests/unit/variant-plan.test.ts new file mode 100644 index 0000000..e9b88f5 --- /dev/null +++ b/tests/unit/variant-plan.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { planVariants, wrapMedia } from '../../src/core/variant-plan' + +describe('planVariants', () => { + it('sans variant : plan vide', () => { + expect(planVariants([])).toEqual({ selectorPrefix: '', selectorSuffix: '', mediaQueries: [], hasDark: false }) + }) + + it('breakpoints min et max, syntaxe v4', () => { + expect(planVariants(['md'])?.mediaQueries).toEqual(['(width >= 48rem)']) + expect(planVariants(['max-lg'])?.mediaQueries).toEqual(['(width < 64rem)']) + }) + + it('breakpoints du site : seuils personnalisés et noms en plus', () => { + const site = { sm: '30rem', '3xl': '120rem' } + expect(planVariants(['sm'], site)?.mediaQueries).toEqual(['(width >= 30rem)']) + expect(planVariants(['max-3xl'], site)?.mediaQueries).toEqual(['(width < 120rem)']) + expect(planVariants(['md'], site)).toBeNull() + }) + + it('pseudo-classes simples cumulées', () => { + expect(planVariants(['hover', 'focus-visible'])?.selectorSuffix).toBe(':hover:focus-visible') + }) + + it('group-* / peer-*', () => { + expect(planVariants(['group-hover'])?.selectorPrefix).toBe('.group:hover ') + expect(planVariants(['peer-focus'])?.selectorPrefix).toBe('.peer:focus ~ ') + expect(planVariants(['group-inconnu'])).toBeNull() + }) + + it('aria-* booléens', () => { + expect(planVariants(['aria-checked'])?.selectorSuffix).toBe('[aria-checked="true"]') + }) + + it('dark', () => { + expect(planVariants(['dark'])?.hasDark).toBe(true) + }) + + it('has-[…] : décode `_` et refuse un fragment déséquilibré', () => { + expect(planVariants(['has-[>_img]'])?.selectorSuffix).toBe(':has(> img)') + expect(planVariants(['has-[a),body_*,x:has(b]'])).toBeNull() + }) + + it('data-[…] : valeur avec ou sans guillemets, clé restreinte', () => { + expect(planVariants(['data-[state=open]'])?.selectorSuffix).toBe('[data-state="open"]') + expect(planVariants(['data-[state="open"]'])?.selectorSuffix).toBe('[data-state="open"]') + expect(planVariants(['data-[open]'])?.selectorSuffix).toBe('[data-open]') + expect(planVariants(['data-[a]{x]'])).toBeNull() + }) + + it('variant inconnu : null', () => { + expect(planVariants(['print-only'])).toBeNull() + }) +}) + +describe('wrapMedia', () => { + it('imbrique les media queries', () => { + expect(wrapMedia('.a { color: red }', ['(min-width: 768px)'])).toBe('@media (min-width: 768px) { .a { color: red } }') + expect(wrapMedia('.a {}', [])).toBe('.a {}') + }) +}) diff --git a/tsconfig.json b/tsconfig.json index 33df56c..cb24f3d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "references": [ { "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }, - { "path": "./tsconfig.tests.json" } + { "path": "./tsconfig.tests.json" }, + { "path": "./tsconfig.unit.json" } ] } diff --git a/tsconfig.node.json b/tsconfig.node.json index 0a89d93..112fdb4 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -20,5 +20,5 @@ "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true }, - "include": ["vite.config.ts", "manifest.config.ts", "playwright.config.ts"] + "include": ["vite.config.ts", "manifest.config.ts", "playwright.config.ts", "vitest.config.ts", "scripts/release.ts"] } diff --git a/tsconfig.unit.json b/tsconfig.unit.json new file mode 100644 index 0000000..ceb6094 --- /dev/null +++ b/tsconfig.unit.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.unit.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["node", "chrome"], + "resolveJsonModule": true, + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["tests/unit/**/*.ts"] +} diff --git a/vite.content.config.ts b/vite.content.config.ts index 8626d82..f00d28b 100644 --- a/vite.content.config.ts +++ b/vite.content.config.ts @@ -1,22 +1,23 @@ import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' -// Build séparé, minimal (pas de plugin CRXJS ici), spécifiquement pour le content script. +// Build séparé, minimal (pas de plugin CRXJS ni React : le content script n'a pas de JSX), +// spécifiquement pour le content script. // Raison : ce fichier est injecté à la demande via chrome.scripting.executeScript({ files }), // qui exécute le fichier comme un script classique — pas un module ES. Le build principal -// (vite.config.ts) produit toujours des chunks ESM avec imports partagés (jsx-runtime, etc.), -// ce qui casserait l'injection. Ici on force IIFE + tout inliné dans un seul fichier autonome. +// (vite.config.ts) produit toujours des chunks ESM avec imports partagés, ce qui casserait +// l'injection. Ici on force IIFE + tout inliné dans un seul fichier autonome. export default defineConfig(({ mode }) => ({ - plugins: [react()], build: { outDir: mode === 'test' ? 'dist-test' : 'dist', // garder en phase avec vite.config.ts emptyOutDir: false, // ne pas effacer la sortie du build principal (vite.config.ts), lancé avant // Le code-splitting est impossible ici (pas juste indésirable) : `inlineDynamicImports` // ci-dessous exige explicitement un seul fichier, imposé par `executeScript({ files })` qui - // injecte un script classique unique. On relève juste le seuil d'avertissement. - chunkSizeWarningLimit: 1000, + // injecte un script classique unique. Seuil volontairement bas : le content script ne doit + // embarquer ni la taxonomie ni le dataset (calculs faits côté devpanel), un avertissement + // signale une régression. + chunkSizeWarningLimit: 50, rollupOptions: { - input: { content: 'src/content/main.tsx' }, + input: { content: 'src/content/main.ts' }, output: { format: 'iife', entryFileNames: 'content/main.js', diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..32e43b6 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config' + +// Config séparée de vite.config.ts : les tests unitaires de `src/core/` n'ont besoin ni du +// plugin CRXJS ni de React. Environnement Node par défaut ; un fichier qui manipule un élément +// DOM le demande lui-même (`// @vitest-environment happy-dom`). +export default defineConfig({ + test: { + include: ['tests/unit/**/*.test.ts'], + }, +})