import js from '@eslint/js'; import { plugin as shadcn } from '@shadcn/lint'; import tsParser from '@typescript-eslint/parser'; import tsPlugin from '@typescript-eslint/eslint-plugin'; import react from 'eslint-plugin-react'; import unusedImports from 'eslint-plugin-unused-imports'; import prettier from 'eslint-plugin-prettier'; import globals from 'globals'; import { cardSurfaceSelectors } from './eslint/card-surfaces.js'; import { everywhereSelectors, pageSelectors } from './eslint/design-rules.js'; // Selectors every file gets: viewport heights, focus return, card surfaces. // On iOS Safari vh (and h-screen, which is 100vh) is the viewport with the // toolbars hidden, so a vh height overflows the visible screen. w-screen is // vw, which the toolbars don't change, so it stays allowed. const baseSyntaxRules = [ ...['Literal[value=/\\dvh\\b/]', 'TemplateElement[value.raw=/\\dvh\\b/]'].map( (selector) => ({ selector, message: 'vh is the toolbar-hidden viewport on iOS Safari. Use dvh for the app shell and caps on things that pop up, svh for a fixed-size panel in a page that scrolls, or max-h-sheet for a bottom sheet. See DESIGN.md.', }), ), ...[ 'Literal[value=/(^|[\\s:])(min-|max-)?h-screen\\b/]', 'TemplateElement[value.raw=/(^|[\\s:])(min-|max-)?h-screen\\b/]', ].map((selector) => ({ selector, message: "Tailwind's screen heights (h-/min-h-/max-h-screen) are the toolbar-hidden viewport on iOS Safari, taller than the visible screen. Use h-dvh / min-h-dvh, or svh for a fixed panel. See DESIGN.md.", })), // Modal, Sheet and DialogContent return focus to what had it on open // (ui/use-focus-return.ts). A hand-rolled return focuses the trigger // even after a tap, which lights its ring on iOS. { selector: 'JSXAttribute[name.name="onCloseAutoFocus"]', message: 'Modal, Sheet and DialogContent already return focus to what had it on open (ui/use-focus-return.ts). Don\'t hand-roll onCloseAutoFocus. See DESIGN.md "Focus return".', }, // Nothing on a filled tile repeats its muted fill (DESIGN.md "Card // surfaces"). ...cardSurfaceSelectors, ]; export default [ { ignores: [ 'node_modules/', 'dist/', 'prettier.config.cjs', 'public/', '.prettierignore', 'package-lock.json', 'package.json', 'postcss.config.cjs', 'tsconfig.json', 'tsconfig.node.json', 'vite.config.ts', ], }, { files: ['**/*.{js,jsx,ts,tsx}'], languageOptions: { ecmaVersion: 'latest', sourceType: 'module', parser: tsParser, parserOptions: { ecmaFeatures: { jsx: true, }, }, globals: { ...globals.browser, ...globals.es2021, ...globals.node, }, }, plugins: { '@typescript-eslint': tsPlugin, react, 'unused-imports': unusedImports, prettier, shadcn, }, rules: { ...js.configs.recommended.rules, ...tsPlugin.configs.recommended.rules, ...react.configs.recommended.rules, ...prettier.configs.recommended.rules, 'react/prop-types': 'off', 'unused-imports/no-unused-imports': 'error', 'react/react-in-jsx-scope': 'off', 'no-undef': 'off', '@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-unused-vars': 'warn', '@typescript-eslint/no-unused-expressions': 'warn', 'prettier/prettier': [ 'error', { endOfLine: 'auto', }, ], // The Radix Dialog wrapper is an internal primitive; app code uses // (or ). Only src/components/ui may import it. 'no-restricted-imports': [ 'error', { patterns: [ { group: ['**/components/ui/dialog'], message: 'Use from @/components/ui/modal; the Dialog primitive is internal to ui/.', }, ], }, ], // DESIGN.md rules a selector can check: viewport heights, focus return // and card surfaces (baseSyntaxRules above), plus the class and markup // rules in eslint/design-rules.js. 'no-restricted-syntax': [ 'error', ...baseSyntaxRules, ...everywhereSelectors, ...pageSelectors, ], // Design-system rules (@shadcn/lint). Tokens, variants and the // approved exceptions are documented in DESIGN.md. 'shadcn/no-restyle': [ 'error', { allow: ['layout'], contracts: [ { pattern: '^Button$', allow: ['layout'], message: { shape: '"{{className}}" is not allowed on