Barazo default frontend barazo.forum

feat(web): add mobile viewport testing and Tailwind lint rules

Add eslint-plugin-better-tailwindcss for Tailwind v4 correctness
checks (conflicting classes, duplicate classes, unknown classes).
Add Playwright mobile audit test suite that runs against staging at
375px viewport, checking horizontal overflow and a11y violations.
Ignore playwright-report/ and test-results/ from both ESLint and git.

Relates to barazo-forum/barazo-workspace#69

+265 -31
+3
.gitignore
··· 12 12 13 13 # testing 14 14 /coverage 15 + /test-results 16 + /playwright-report 17 + /e2e/screenshots 15 18 16 19 # next.js 17 20 /.next/
+34
e2e/mobile-audit.config.ts
··· 1 + import { defineConfig } from '@playwright/test' 2 + 3 + /** 4 + * Playwright config for mobile audit against staging. 5 + * No local webServer needed -- tests hit staging directly. 6 + */ 7 + export default defineConfig({ 8 + testDir: '.', 9 + testMatch: 'mobile-audit.spec.ts', 10 + 11 + retries: 0, 12 + workers: 1, 13 + 14 + reporter: [['html', { open: 'on-failure' }], ['list']], 15 + 16 + use: { 17 + baseURL: 'https://staging.barazo.forum', 18 + viewport: { width: 375, height: 812 }, 19 + deviceScaleFactor: 3, 20 + isMobile: true, 21 + hasTouch: true, 22 + userAgent: 23 + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1', 24 + screenshot: 'only-on-failure', 25 + trace: 'retain-on-failure', 26 + }, 27 + 28 + projects: [ 29 + { 30 + name: 'mobile-audit', 31 + use: {}, 32 + }, 33 + ], 34 + })
+86
e2e/mobile-audit.spec.ts
··· 1 + import { test, expect } from '@playwright/test' 2 + import AxeBuilder from '@axe-core/playwright' 3 + import { join } from 'node:path' 4 + 5 + /** 6 + * One-off mobile viewport audit against staging. 7 + * Checks horizontal overflow and a11y at 375px width. 8 + * Run: pnpm exec playwright test e2e/mobile-audit.spec.ts --config e2e/mobile-audit.config.ts 9 + */ 10 + 11 + const pages = [ 12 + { name: 'Homepage', path: '/' }, 13 + { name: 'Category page', path: '/c/general/' }, 14 + { name: 'Topic page', path: '/t/test-topic/abc123/' }, 15 + { name: 'Search page', path: '/search/' }, 16 + { name: 'Admin dashboard', path: '/admin/' }, 17 + { name: 'Settings page', path: '/settings/' }, 18 + { name: 'Profile page', path: '/u/jay/' }, 19 + { name: 'Accessibility statement', path: '/accessibility/' }, 20 + { name: 'Login page', path: '/login/' }, 21 + { name: 'Legal - Privacy', path: '/legal/privacy/' }, 22 + { name: 'Legal - Terms', path: '/legal/terms/' }, 23 + ] 24 + 25 + const WCAG_TAGS = ['wcag2a', 'wcag2aa', 'wcag22aa'] as const 26 + 27 + test.describe('Mobile viewport audit (375px)', () => { 28 + for (const { name, path } of pages) { 29 + test(`${name} (${path}) — no horizontal overflow`, async ({ page }) => { 30 + await page.goto(path, { waitUntil: 'domcontentloaded' }) 31 + // Allow layout to settle 32 + await page.waitForTimeout(1000) 33 + 34 + // Screenshot for visual review 35 + await page.screenshot({ 36 + path: join( 37 + 'e2e', 38 + 'screenshots', 39 + 'mobile', 40 + `${name.toLowerCase().replace(/\s+/g, '-')}.png` 41 + ), 42 + fullPage: true, 43 + }) 44 + 45 + // Check for horizontal overflow 46 + const hasOverflow = await page.evaluate(() => { 47 + return document.documentElement.scrollWidth > document.documentElement.clientWidth 48 + }) 49 + 50 + if (hasOverflow) { 51 + // Find the overflowing elements for debugging 52 + const overflowingElements = await page.evaluate(() => { 53 + const viewportWidth = document.documentElement.clientWidth 54 + const elements: string[] = [] 55 + document.querySelectorAll('*').forEach((el) => { 56 + const rect = el.getBoundingClientRect() 57 + if (rect.right > viewportWidth + 1) { 58 + const tag = el.tagName.toLowerCase() 59 + const id = el.id ? `#${el.id}` : '' 60 + const cls = 61 + el.className && typeof el.className === 'string' 62 + ? `.${el.className.split(' ').slice(0, 3).join('.')}` 63 + : '' 64 + elements.push(`${tag}${id}${cls} (right: ${Math.round(rect.right)}px)`) 65 + } 66 + }) 67 + return elements.slice(0, 10) 68 + }) 69 + 70 + expect( 71 + hasOverflow, 72 + `Horizontal overflow detected. Overflowing elements:\n${overflowingElements.join('\n')}` 73 + ).toBe(false) 74 + } 75 + }) 76 + 77 + test(`${name} (${path}) — no a11y violations at mobile`, async ({ page }) => { 78 + await page.goto(path, { waitUntil: 'domcontentloaded' }) 79 + await page.waitForTimeout(1000) 80 + 81 + const results = await new AxeBuilder({ page }).withTags([...WCAG_TAGS]).analyze() 82 + 83 + expect(results.violations).toEqual([]) 84 + }) 85 + } 86 + })
+33 -1
eslint.config.mjs
··· 1 1 import { defineConfig, globalIgnores } from 'eslint/config' 2 2 import nextVitals from 'eslint-config-next/core-web-vitals' 3 3 import nextTs from 'eslint-config-next/typescript' 4 + import tailwindcss from 'eslint-plugin-better-tailwindcss' 4 5 5 6 const eslintConfig = defineConfig([ 6 7 ...nextVitals, 7 8 ...nextTs, 9 + // Tailwind CSS correctness rules (v4-compatible) 10 + { 11 + plugins: { 'better-tailwindcss': tailwindcss }, 12 + settings: { 13 + 'better-tailwindcss': { 14 + entryPoint: 'src/app/globals.css', 15 + }, 16 + }, 17 + rules: { 18 + // warn only: false positives from tailwindcss-animate and prose classes 19 + 'better-tailwindcss/no-unknown-classes': 'warn', 20 + 'better-tailwindcss/no-conflicting-classes': 'error', 21 + 'better-tailwindcss/no-duplicate-classes': 'warn', 22 + }, 23 + }, 24 + // Disable Tailwind rules for test files (intentional fake class names) 25 + { 26 + files: ['**/*.test.ts', '**/*.test.tsx', '**/*.spec.ts', '**/*.spec.tsx'], 27 + rules: { 28 + 'better-tailwindcss/no-unknown-classes': 'off', 29 + }, 30 + }, 8 31 // Configure jsx-a11y rules without redefining the plugin 9 32 // (eslint-config-next already includes jsx-a11y plugin) 10 33 { ··· 55 78 }, 56 79 }, 57 80 // Override default ignores of eslint-config-next. 58 - globalIgnores(['.next/**', 'dist/**', 'out/**', 'build/**', 'next-env.d.ts', 'node_modules/**']), 81 + globalIgnores([ 82 + '.next/**', 83 + 'dist/**', 84 + 'out/**', 85 + 'build/**', 86 + 'next-env.d.ts', 87 + 'node_modules/**', 88 + 'playwright-report/**', 89 + 'test-results/**', 90 + ]), 59 91 ]) 60 92 61 93 export default eslintConfig
+1
package.json
··· 88 88 "babel-plugin-react-compiler": "1.0.0", 89 89 "eslint": "9.39.3", 90 90 "eslint-config-next": "16.1.6", 91 + "eslint-plugin-better-tailwindcss": "4.3.1", 91 92 "eslint-plugin-jsx-a11y": "6.10.2", 92 93 "husky": "catalog:", 93 94 "jsdom": "28.1.0",
+108 -30
pnpm-lock.yaml
··· 4 4 autoInstallPeers: true 5 5 excludeLinksFromLockfile: false 6 6 7 - catalogs: 8 - default: 9 - '@commitlint/cli': 10 - specifier: 20.4.2 11 - version: 20.4.2 12 - '@commitlint/config-conventional': 13 - specifier: 20.4.2 14 - version: 20.4.2 15 - '@types/node': 16 - specifier: 25.3.0 17 - version: 25.3.0 18 - husky: 19 - specifier: 9.1.7 20 - version: 9.1.7 21 - lint-staged: 22 - specifier: 16.2.7 23 - version: 16.2.7 24 - prettier: 25 - specifier: 3.8.1 26 - version: 3.8.1 27 - typescript: 28 - specifier: 5.9.3 29 - version: 5.9.3 30 - vitest: 31 - specifier: 4.0.18 32 - version: 4.0.18 33 - zod: 34 - specifier: 4.3.6 35 - version: 4.3.6 36 - 37 7 importers: 38 8 39 9 .: ··· 213 183 eslint-config-next: 214 184 specifier: 16.1.6 215 185 version: 16.1.6(@typescript-eslint/parser@8.55.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) 186 + eslint-plugin-better-tailwindcss: 187 + specifier: ^4.3.1 188 + version: 4.3.1(eslint@9.39.3(jiti@2.6.1))(tailwindcss@4.2.1)(typescript@5.9.3) 216 189 eslint-plugin-jsx-a11y: 217 190 specifier: 6.10.2 218 191 version: 6.10.2(eslint@9.39.3(jiti@2.6.1)) ··· 651 624 resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} 652 625 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 653 626 627 + '@eslint/css-tree@3.6.9': 628 + resolution: {integrity: sha512-3D5/OHibNEGk+wKwNwMbz63NMf367EoR4mVNNpxddCHKEb2Nez7z62J2U6YjtErSsZDoY0CsccmoUpdEbkogNA==} 629 + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} 630 + 654 631 '@eslint/eslintrc@3.3.3': 655 632 resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} 656 633 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} ··· 1016 993 peerDependencies: 1017 994 react: '>= 16.8' 1018 995 react-dom: '>= 16.8' 996 + 997 + '@pkgr/core@0.2.9': 998 + resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} 999 + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} 1019 1000 1020 1001 '@playwright/test@1.58.2': 1021 1002 resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} ··· 2207 2188 cpu: [x64] 2208 2189 os: [win32] 2209 2190 2191 + '@valibot/to-json-schema@1.5.0': 2192 + resolution: {integrity: sha512-GE7DmSr1C2UCWPiV0upRH6mv0cCPsqYGs819fb6srCS1tWhyXrkGGe+zxUiwzn/L1BOfADH4sNjY/YHCuP8phQ==} 2193 + peerDependencies: 2194 + valibot: ^1.2.0 2195 + 2210 2196 '@vitejs/plugin-react@5.1.4': 2211 2197 resolution: {integrity: sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==} 2212 2198 engines: {node: ^20.19.0 || >=22.12.0} ··· 3038 3024 eslint-import-resolver-webpack: 3039 3025 optional: true 3040 3026 3027 + eslint-plugin-better-tailwindcss@4.3.1: 3028 + resolution: {integrity: sha512-b6xM31GukKz0WlgMD0tQdY/rLjf/9mWIk8EcA45ngOKJPPQf1C482xZtBlT357jyunQE2mOk4NlPcL4i9Pr85A==} 3029 + engines: {node: ^20.19.0 || ^22.12.0 || >=23.0.0} 3030 + peerDependencies: 3031 + eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 3032 + oxlint: ^1.35.0 3033 + tailwindcss: ^3.3.0 || ^4.1.17 3034 + peerDependenciesMeta: 3035 + eslint: 3036 + optional: true 3037 + oxlint: 3038 + optional: true 3039 + 3041 3040 eslint-plugin-import@2.32.0: 3042 3041 resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} 3043 3042 engines: {node: '>=4'} ··· 3924 3923 mdn-data@2.12.2: 3925 3924 resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} 3926 3925 3926 + mdn-data@2.23.0: 3927 + resolution: {integrity: sha512-786vq1+4079JSeu2XdcDjrhi/Ry7BWtjDl9WtGPWLiIHb2T66GvIVflZTBoSNZ5JqTtJGYEVMuFA/lbQlMOyDQ==} 3928 + 3927 3929 media-typer@0.3.0: 3928 3930 resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} 3929 3931 engines: {node: '>= 0.6'} ··· 4828 4830 symbol-tree@3.2.4: 4829 4831 resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} 4830 4832 4833 + synckit@0.11.12: 4834 + resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} 4835 + engines: {node: ^14.18.0 || >=16.0.0} 4836 + 4831 4837 tagged-tag@1.0.0: 4832 4838 resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} 4833 4839 engines: {node: '>=20'} 4834 4840 4841 + tailwind-csstree@0.1.4: 4842 + resolution: {integrity: sha512-FzD187HuFIZEyeR7Xy6sJbJll2d4SybS90satC8SKIuaNRC05CxMvdzN7BUsfDQffcnabckRM5OIcfArjsZ0mg==} 4843 + engines: {node: '>=18.18'} 4844 + 4835 4845 tailwind-merge@3.5.0: 4836 4846 resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} 4837 4847 ··· 4936 4946 peerDependencies: 4937 4947 typescript: '>=4.8.4' 4938 4948 4949 + tsconfig-paths-webpack-plugin@4.2.0: 4950 + resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==} 4951 + engines: {node: '>=10.13.0'} 4952 + 4939 4953 tsconfig-paths@3.15.0: 4940 4954 resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} 4955 + 4956 + tsconfig-paths@4.2.0: 4957 + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} 4958 + engines: {node: '>=6'} 4941 4959 4942 4960 tslib@1.14.1: 4943 4961 resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} ··· 5076 5094 uuid@8.3.2: 5077 5095 resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} 5078 5096 hasBin: true 5097 + 5098 + valibot@1.2.0: 5099 + resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} 5100 + peerDependencies: 5101 + typescript: '>=5' 5102 + peerDependenciesMeta: 5103 + typescript: 5104 + optional: true 5079 5105 5080 5106 vary@1.1.2: 5081 5107 resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} ··· 5749 5775 dependencies: 5750 5776 '@types/json-schema': 7.0.15 5751 5777 5778 + '@eslint/css-tree@3.6.9': 5779 + dependencies: 5780 + mdn-data: 2.23.0 5781 + source-map-js: 1.2.1 5782 + 5752 5783 '@eslint/eslintrc@3.3.3': 5753 5784 dependencies: 5754 5785 ajv: 6.12.6 ··· 6094 6125 dependencies: 6095 6126 react: 19.2.4 6096 6127 react-dom: 19.2.4(react@19.2.4) 6128 + 6129 + '@pkgr/core@0.2.9': {} 6097 6130 6098 6131 '@playwright/test@1.58.2': 6099 6132 dependencies: ··· 7291 7324 '@unrs/resolver-binding-win32-x64-msvc@1.11.1': 7292 7325 optional: true 7293 7326 7327 + '@valibot/to-json-schema@1.5.0(valibot@1.2.0(typescript@5.9.3))': 7328 + dependencies: 7329 + valibot: 1.2.0(typescript@5.9.3) 7330 + 7294 7331 '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2))': 7295 7332 dependencies: 7296 7333 '@babel/core': 7.29.0 ··· 8250 8287 transitivePeerDependencies: 8251 8288 - supports-color 8252 8289 8290 + eslint-plugin-better-tailwindcss@4.3.1(eslint@9.39.3(jiti@2.6.1))(tailwindcss@4.2.1)(typescript@5.9.3): 8291 + dependencies: 8292 + '@eslint/css-tree': 3.6.9 8293 + '@valibot/to-json-schema': 1.5.0(valibot@1.2.0(typescript@5.9.3)) 8294 + enhanced-resolve: 5.19.0 8295 + jiti: 2.6.1 8296 + synckit: 0.11.12 8297 + tailwind-csstree: 0.1.4 8298 + tailwindcss: 4.2.1 8299 + tsconfig-paths-webpack-plugin: 4.2.0 8300 + valibot: 1.2.0(typescript@5.9.3) 8301 + optionalDependencies: 8302 + eslint: 9.39.3(jiti@2.6.1) 8303 + transitivePeerDependencies: 8304 + - typescript 8305 + 8253 8306 eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)): 8254 8307 dependencies: 8255 8308 '@rtsao/scc': 1.1.0 ··· 9300 9353 9301 9354 mdn-data@2.12.2: {} 9302 9355 9356 + mdn-data@2.23.0: {} 9357 + 9303 9358 media-typer@0.3.0: {} 9304 9359 9305 9360 meow@12.1.1: {} ··· 10350 10405 10351 10406 symbol-tree@3.2.4: {} 10352 10407 10408 + synckit@0.11.12: 10409 + dependencies: 10410 + '@pkgr/core': 0.2.9 10411 + 10353 10412 tagged-tag@1.0.0: {} 10413 + 10414 + tailwind-csstree@0.1.4: {} 10354 10415 10355 10416 tailwind-merge@3.5.0: {} 10356 10417 ··· 10452 10513 dependencies: 10453 10514 typescript: 5.9.3 10454 10515 10516 + tsconfig-paths-webpack-plugin@4.2.0: 10517 + dependencies: 10518 + chalk: 4.1.2 10519 + enhanced-resolve: 5.19.0 10520 + tapable: 2.3.0 10521 + tsconfig-paths: 4.2.0 10522 + 10455 10523 tsconfig-paths@3.15.0: 10456 10524 dependencies: 10457 10525 '@types/json5': 0.0.29 10458 10526 json5: 1.0.2 10527 + minimist: 1.2.8 10528 + strip-bom: 3.0.0 10529 + 10530 + tsconfig-paths@4.2.0: 10531 + dependencies: 10532 + json5: 2.2.3 10459 10533 minimist: 1.2.8 10460 10534 strip-bom: 3.0.0 10461 10535 ··· 10628 10702 utils-merge@1.0.1: {} 10629 10703 10630 10704 uuid@8.3.2: {} 10705 + 10706 + valibot@1.2.0(typescript@5.9.3): 10707 + optionalDependencies: 10708 + typescript: 5.9.3 10631 10709 10632 10710 vary@1.1.2: {} 10633 10711