Files
dshandGitHub 099aa46b05 chore: UI fixes (Sidebar, Welcome card) (#8500)
* fix: UI adjustments

* fix: sidebar css

* fix: sidebar css colors

* fix: sidebar separator

* fix: linter

* fix: hover grey

* fix: icons stroke to 1
2026-08-13 10:03:51 +02:00

317 lines
17 KiB
JavaScript

import unusedImports from 'eslint-plugin-unused-imports'
import typescriptEslint from '@typescript-eslint/eslint-plugin'
import noOnlyTests from 'eslint-plugin-no-only-tests'
import tsParser from '@typescript-eslint/parser'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import js from '@eslint/js'
import { FlatCompat } from '@eslint/eslintrc'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
})
// Design-system button-styling guard: flags size/skin utilities (owned by the
// `size`/`variant` props) set via `className` on a <Button> or a closed button
// preset. Matches the literal even inside cn(...). See .storybook/AGENTS.md.
const dsButtonClassnameRule = (element, message) => ({
selector: `JSXOpeningElement[name.name='${element}'] > JSXAttribute[name.name='className'] Literal[value=/(?:^|\\s)(h-|px-|py-|text-(xs|sm|base|lg)|rounded-|bg-)/]`,
message,
})
// Design-system Card-styling guard. Card owns spacing (gap/padding), radius, and
// surface (bg/border/shadow) via its `size`/`variant`/`radius` props — so this rule
// flags a wider set than the button rule: `gap-`, full-side `p-`/`pt-`/`pb-`, `border`,
// and `shadow-`. `className` stays layout-only (w-*, margins, flex/grid). Matches the
// literal even inside cn(...). Escape hatch: `// eslint-disable-next-line no-restricted-syntax -- <reason>`.
const dsCardClassnameRule = (element, message) => ({
selector: `JSXOpeningElement[name.name='${element}'] > JSXAttribute[name.name='className'] Literal[value=/(?:^|\\s)(h-|p-|px-|py-|pt-|pb-|pl-|pr-|gap-|text-(xs|sm|base|lg)|rounded-|bg-|border|shadow-)/]`,
message,
})
// Design-system Input-styling guard. Input/InputGroup own height (`inputSize`) and skin
// (`variant`: bg/border) — so this flags `h-`, `px-`/`py-`, `rounded-`, `bg-`, `border`, and
// font sizes on className. `w-*`, margins, and flex/grid stay layout-only. Matches literals
// inside cn(...). Escape hatch: `// eslint-disable-next-line no-restricted-syntax -- <reason>`.
const dsInputClassnameRule = (element, message) => ({
selector: `JSXOpeningElement[name.name='${element}'] > JSXAttribute[name.name='className'] Literal[value=/(?:^|\\s)(h-|px-|py-|text-(xs|sm|base|lg)|rounded-|bg-|border)/]`,
message,
})
// Design-system Tabs-styling guard. TabsList owns bg/padding/height/radius/gap via its
// `variant` (underline/toggle, + tone/size) and TabsTrigger owns its per-variant styling — so
// call sites pass only `variant` + layout-only className (w-*, margins, flex/grid). Same wide
// regex as Card. Escape hatch: `// eslint-disable-next-line no-restricted-syntax -- <reason>`.
const dsTabsClassnameRule = (element, message) => ({
selector: `JSXOpeningElement[name.name='${element}'] > JSXAttribute[name.name='className'] Literal[value=/(?:^|\\s)(h-|p-|px-|py-|pt-|pb-|pl-|pr-|gap-|text-(xs|sm|base|lg)|rounded-|bg-|border|shadow-)/]`,
message,
})
// Design-system Badge/Chip-styling guard. Badge/Chip own geometry (`size`/`shape`) and
// colour (`variant`) — so this flags `h-`, `px-`/`py-`, font sizes (incl. arbitrary
// `text-[10px]`/`text-[var(--…)]`), `rounded-`, `bg-`, and `border`. `w-*`, margins, and
// flex/grid stay layout-only. Matches literals inside cn(...). Escape hatch:
// `// eslint-disable-next-line no-restricted-syntax -- <reason>`.
const dsBadgeClassnameRule = (element, message) => ({
selector: `JSXOpeningElement[name.name='${element}'] > JSXAttribute[name.name='className'] Literal[value=/(?:^|\\s)(h-|px-|py-|text-(xs|sm|base|lg)|text-\\[|rounded-|bg-|border)/]`,
message,
})
// Design-system Dialog/Sheet/Drawer-styling guard. Content owns width (`size`), body
// `padding`, and `surface` (bg/border/shadow); Header/Footer own `divided`. So this flags
// `max-w-`, arbitrary `w-[…]`, `p-`/`px-`/`py-`/`pt-`/`pb-`, `gap-`, `rounded-`, `bg-`,
// `border`, `shadow-`. It deliberately does NOT flag `max-h-`, `w-full`, `w-3/4`, flex/grid
// or overflow — those stay layout-only. Matches literals inside cn(...). Escape hatch:
// `// eslint-disable-next-line no-restricted-syntax -- <reason>`.
const dsDialogClassnameRule = (element, message) => ({
selector: `JSXOpeningElement[name.name='${element}'] > JSXAttribute[name.name='className'] Literal[value=/(?:^|\\s)(p-|px-|py-|pt-|pb-|gap-|max-w-|w-\\[|rounded-|bg-|border|shadow-)/]`,
message,
})
export default [
{
ignores: [
'**/node_modules/',
'**/.next/',
'**/.github/',
'**/cypress/',
'**/cypress.config.js',
'**/src/types/contracts/',
'**/.storybook/test-runner.mjs',
'**/.storybook/mocks/*.js',
'**/public/mockServiceWorker.js',
],
},
...compat.extends('next', 'prettier', 'plugin:storybook/recommended'),
{
plugins: {
'unused-imports': unusedImports,
'@typescript-eslint': typescriptEslint,
'no-only-tests': noOnlyTests,
},
languageOptions: {
parser: tsParser,
ecmaVersion: 5,
sourceType: 'script',
parserOptions: {
project: ['./tsconfig.json'],
},
},
rules: {
'@next/next/no-img-element': 'off',
'@next/next/google-font-display': 'off',
'@next/next/google-font-preconnect': 'off',
'@next/next/no-page-custom-font': 'off',
'unused-imports/no-unused-imports': 'error',
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/await-thenable': 'error',
'no-constant-condition': 'warn',
'unused-imports/no-unused-vars': [
'error',
{
varsIgnorePattern: '^_',
ignoreRestSiblings: true,
},
],
'react-hooks/exhaustive-deps': [
'warn',
{
additionalHooks: 'useAsync',
},
],
'no-only-tests/no-only-tests': 'error',
'object-shorthand': ['error', 'properties'],
'jsx-quotes': ['error', 'prefer-double'],
'react/jsx-curly-brace-presence': [
'error',
{
props: 'never',
children: 'never',
},
],
// Feature architecture: Prevent importing feature internals from outside the feature
// This enforces that features expose a clean public API through their index.ts barrel file
//
// ALLOWED imports:
// @/features/myfeature - main barrel (components via useLoadFeature, types, lightweight hooks)
// @/features/myfeature/store - Redux store (slice, selectors, actions) - needed at store init
// @/features/myfeature/services - services barrel (lightweight utilities only)
//
// FORBIDDEN imports (will cause bundle bloat):
// @/features/myfeature/components/* - use useLoadFeature() instead
// @/features/myfeature/hooks/* - export through feature barrel if lightweight
// @/features/myfeature/services/* - heavy services should be in contract, accessed via useLoadFeature()
//
// See apps/web/docs/feature-architecture.md for details
'no-restricted-imports': [
'error',
{
patterns: [
// Block deep imports into feature components (defeats lazy loading)
{
group: ['@/features/*/components', '@/features/*/components/**'],
message:
'Do not import components directly. Use useLoadFeature() to access lazy-loaded components. See docs/feature-architecture.md',
},
// Block deep imports into feature hooks (should go through barrel)
{
group: ['@/features/*/hooks', '@/features/*/hooks/**'],
message: 'Import hooks from the feature barrel (@/features/myfeature) not from hooks folder directly.',
},
// Block deep imports into services internal files (barrel is OK for lightweight utils)
{
group: ['@/features/*/services/*', '!@/features/*/services/index'],
message:
'Import from @/features/myfeature/services (barrel) for lightweight utils, or use useLoadFeature() for heavy services.',
},
// Block deep imports into store internal files (barrel is OK)
{
group: ['@/features/*/store/*', '!@/features/*/store/index'],
message: 'Import from @/features/myfeature/store (barrel) not from internal store files.',
},
// Block internal file imports (handle.ts is internal, only index.ts is public)
{
group: ['@/features/*/handle'],
message: 'Import from feature index file only. The handle is internal - use @/features/{name} instead.',
},
// Same patterns for relative imports
{
// Same for relative imports
group: [
'../features/*/components',
'../features/*/components/**',
'../../features/*/components',
'../../features/*/components/**',
],
message: 'Do not import components directly. Use useLoadFeature() instead.',
},
],
},
],
// Design-system consistency: on <Button>, `className` is for LAYOUT only
// (w-full, margins, grid placement). Height/padding/font-size/radius/background
// are owned by the `size`/`variant` props — overriding them via className is what
// makes buttons drift out of sync. If no size/variant fits a recurring need, add one
// to components/ui/button.tsx. See the UI/Button story + .storybook/AGENTS.md
// ("Component variants over custom styling").
'no-restricted-syntax': [
'error',
dsButtonClassnameRule(
'Button',
"Don't set size/skin utilities (h-*, px-*/py-*, text-xs|sm|base|lg, rounded-*, bg-*) on <Button> — use a `size`/`variant` prop. See the UI/Button story and .storybook/AGENTS.md; add a variant/size to components/ui/button.tsx if none fits. The only sanctioned raw-styling escape is `// eslint-disable-next-line no-restricted-syntax -- <reason>`.",
),
dsButtonClassnameRule(
'SubmitButton',
'SubmitButton is a closed preset and takes no styling className — use `fullWidth` for layout, or the primitive <Button> for a genuine one-off.',
),
dsButtonClassnameRule(
'ActionButton',
'ActionButton is a closed preset and takes no styling className — use `fullWidth` for layout, or the primitive <Button> for a genuine one-off.',
),
dsButtonClassnameRule(
'SelectTrigger',
"Don't set size/skin utilities (h-*, px-*/py-*, text-xs|sm|base|lg, rounded-*, bg-*) on <SelectTrigger> — use `size` ('sm'|'default'|'lg' — match the height of the button/field on the same row) / `variant` ('default'|'ghost'). See the UI/Select story; add a variant to components/ui/select.tsx if none fits. Escape hatch: `// eslint-disable-next-line no-restricted-syntax -- <reason>`.",
),
...['Card', 'CardHeader', 'CardContent', 'CardFooter', 'CardTitle', 'CardDescription', 'CardAction'].map(
(element) =>
dsCardClassnameRule(
element,
`Don't set spacing/surface/radius utilities (gap-*, p-*/px-*/py-*, rounded-*, bg-*, border, shadow-*, text-xs|sm|base|lg) on <${element}> — use the \`size\` ('sm'|'default'|'lg'|'none'), \`variant\` ('outlined'|'muted'), and \`radius\` props. \`className\` is layout-only (w-*, margins, flex/grid). See the UI/Card story; add a variant/size to components/ui/card.tsx if none fits. Escape hatch: \`// eslint-disable-next-line no-restricted-syntax -- <reason>\`.`,
),
),
dsCardClassnameRule(
'SettingsCard',
'SettingsCard is a Card preset — pass layout-only `className` (w-*, margins, flex/grid), not spacing/surface/radius utilities. Use `contentClassName` for the body or the primitive <Card> for a one-off. Escape hatch: `// eslint-disable-next-line no-restricted-syntax -- <reason>`.',
),
dsCardClassnameRule(
'SpaceSettingsSection',
'SpaceSettingsSection is a Card preset — pass layout-only `className`, not spacing/surface/radius utilities. Escape hatch: `// eslint-disable-next-line no-restricted-syntax -- <reason>`.',
),
dsCardClassnameRule(
'TxCard',
'TxCard is a Card preset — pass layout-only `className`, not spacing/surface/radius utilities. Escape hatch: `// eslint-disable-next-line no-restricted-syntax -- <reason>`.',
),
...[
'Input',
'InputGroup',
'InputGroupInput',
'InputGroupAddon',
'InputGroupText',
'InputGroupTextarea',
'InputGroupButton',
].map((element) =>
dsInputClassnameRule(
element,
`Don't set height/skin utilities (h-*, px-*/py-*, text-xs|sm|base|lg, rounded-*, bg-*, border) on <${element}> — use \`inputSize\` ('sm'|'default'|'lg'|'hero' — match the height of the button on the same row) / \`variant\` ('default'|'surface', plus 'search'|'outline' on InputGroup). \`className\` is layout-only (w-*, margins, flex/grid). See the UI/Input story; add a variant to components/ui/input.tsx if none fits. Escape hatch: \`// eslint-disable-next-line no-restricted-syntax -- <reason>\`.`,
),
),
dsInputClassnameRule(
'SearchInput',
'SearchInput is the shared search preset — pass layout-only `className` (w-*, margins), not height/skin utilities. Use `inputSize`/`variant`, or the primitive <InputGroup> for a one-off. Escape hatch: `// eslint-disable-next-line no-restricted-syntax -- <reason>`.',
),
dsInputClassnameRule(
'SearchField',
'SearchField is a search preset (being retired for <SearchInput>) — pass layout-only `className`, not height/skin utilities. Escape hatch: `// eslint-disable-next-line no-restricted-syntax -- <reason>`.',
),
dsInputClassnameRule(
'NumberField',
'NumberField forwards styling to its Input — pass `inputSize`/`variant`, not height/skin utilities via `className`. Escape hatch: `// eslint-disable-next-line no-restricted-syntax -- <reason>`.',
),
dsInputClassnameRule(
'NameInput',
'NameInput forwards styling to its Input — pass `inputSize`/`variant`, not height/skin utilities via `className`. Escape hatch: `// eslint-disable-next-line no-restricted-syntax -- <reason>`.',
),
...['TabsList', 'TabsTrigger'].map((element) =>
dsTabsClassnameRule(
element,
`Don't set spacing/surface/radius utilities (gap-*, p-*/px-*/py-*, rounded-*, bg-*, border, shadow-*, text-xs|sm|base|lg) on <${element}> — use the TabsList \`variant\` ('underline'|'toggle', with tone/size). \`className\` is layout-only (w-*, margins, flex/grid). See the UI/Tabs story; add a variant to components/ui/tabs.tsx if none fits. Escape hatch: \`// eslint-disable-next-line no-restricted-syntax -- <reason>\`.`,
),
),
...['Badge', 'Chip'].map((element) =>
dsBadgeClassnameRule(
element,
`Don't set geometry/colour utilities (h-*, px-*/py-*, text-xs|sm|base|lg, text-[…], rounded-*, bg-*, border) on <${element}> — use the \`variant\`, \`size\` ('sm'|'default'|'lg'|'auto'), and \`shape\` ('pill'|'tag') props. \`className\` is layout-only (w-*, margins, flex/grid). See the UI/${element} story; add a variant to components/ui/${element.toLowerCase()}.tsx if none fits. Escape hatch: \`// eslint-disable-next-line no-restricted-syntax -- <reason>\`.`,
),
),
...[
'DialogContent',
'DialogHeader',
'DialogFooter',
'SheetContent',
'SheetHeader',
'SheetFooter',
'DrawerContent',
'DrawerHeader',
'DrawerFooter',
].map((element) =>
dsDialogClassnameRule(
element,
`Don't set width/padding/surface utilities (max-w-*, w-[…], p-*/px-*/py-*, gap-*, rounded-*, bg-*, border, shadow-*) on <${element}> — use the \`size\`, \`padding\`, \`surface\`, and (Header/Footer) \`divided\` props. Layout-only className (max-h-*, w-full, flex/grid, overflow) is fine. See the UI/Dialog|Sheet|Drawer story; add a variant to the primitive if none fits. Escape hatch: \`// eslint-disable-next-line no-restricted-syntax -- <reason>\`.`,
),
),
],
},
},
// Override for story files: allow type-only imports from @storybook/react
// since @storybook/nextjs re-exports these types but TypeScript doesn't always resolve them correctly
{
files: ['**/*.stories.tsx', '**/*.stories.ts'],
rules: {
'storybook/no-renderer-packages': 'off',
},
},
]