diff --git a/apps/ui/src/features/account/account-prompt/style.module.css b/apps/ui/src/features/account/account-prompt/style.module.css index a8157f3f4f..39d657afa1 100644 --- a/apps/ui/src/features/account/account-prompt/style.module.css +++ b/apps/ui/src/features/account/account-prompt/style.module.css @@ -9,6 +9,7 @@ text-wrap: pretty; } +/* eslint-disable-next-line studio/no-primitive-overrides -- the prompt drops its dialog offsets inside an Empty, which spaces it already. */ :global( [data-slot='empty'] ) > .body { margin-top: 0; padding-bottom: 0; diff --git a/apps/ui/src/features/chats/session-view/conversation/agent-question/style.module.css b/apps/ui/src/features/chats/session-view/conversation/agent-question/style.module.css index f694502a3d..8aa0ddb5b8 100644 --- a/apps/ui/src/features/chats/session-view/conversation/agent-question/style.module.css +++ b/apps/ui/src/features/chats/session-view/conversation/agent-question/style.module.css @@ -52,6 +52,7 @@ cursor: pointer; } +/* eslint-disable-next-line studio/no-primitive-overrides -- the whole folded summary is the button, so its hover lights the answer. */ .questionSummary:is( :hover, :focus-visible ) [data-slot='questionnaire-answer'] { background-color: var( --hover ); } @@ -150,6 +151,7 @@ box-shadow: var( --shadow-raised ); } +/* eslint-disable-next-line studio/no-primitive-overrides -- the card folds away the choices that weren't picked. */ .questionCard[data-state='folding'] [data-slot='questionnaire-choice']:not( [data-checked] ) { animation: questionAnsweredElementFade var( --motion-duration-surface ) ease-out forwards; } @@ -185,6 +187,7 @@ } @media ( prefers-reduced-motion: reduce ) { + /* eslint-disable-next-line studio/no-primitive-overrides -- the card folds away the choices that weren't picked. */ .questionBatchStep, .questionBatchStep[data-state='answered'] .questionProgress, .questionCard[data-state='folding'] [data-slot='questionnaire-choice']:not( [data-checked] ), diff --git a/apps/ui/src/features/notifications/app-toasts/index.tsx b/apps/ui/src/features/notifications/app-toasts/index.tsx index ddc9cafddb..9cf3788997 100644 --- a/apps/ui/src/features/notifications/app-toasts/index.tsx +++ b/apps/ui/src/features/notifications/app-toasts/index.tsx @@ -1,3 +1,4 @@ +// eslint-disable-next-line no-restricted-imports -- toasts render only here, so Toast has no primitive. import { Toast } from '@base-ui/react/toast'; import { __ } from '@wordpress/i18n'; import { useEffect, useRef, useState, type FocusEvent } from 'react'; diff --git a/apps/ui/src/features/settings/settings-view/style.module.css b/apps/ui/src/features/settings/settings-view/style.module.css index 8d411e279c..6599cec37d 100644 --- a/apps/ui/src/features/settings/settings-view/style.module.css +++ b/apps/ui/src/features/settings/settings-view/style.module.css @@ -149,6 +149,7 @@ } /* Signed out or offline: grey the figures, since none of them is live. */ +/* eslint-disable-next-line studio/no-primitive-overrides -- Field has no disabled look for a whole group. */ .usageDisabled :is( [data-slot='field-label'], [data-slot='field-legend'] ) { color: var( --muted-foreground ); } @@ -179,10 +180,12 @@ gap: var( --space-1 ) var( --space-4 ); } +/* eslint-disable-next-line studio/no-primitive-overrides -- the meter's label row holds an inline button. */ .meterSummary > [data-slot='field-label'] { gap: var( --space-1 ); } +/* eslint-disable-next-line studio/no-primitive-overrides -- the meter's label row holds an inline button. */ .meterSummary > [data-slot='field-label'] > button { margin-block: calc( var( --space-1 ) * -1 ); } @@ -190,16 +193,19 @@ /* Escalation colors for the AI credits meter (80% / 90% / exhausted). The filters brighten the warning token toward the design's yellow and orange — taken as-is from the usage-interfaces exploration. */ +/* eslint-disable-next-line studio/no-primitive-overrides -- Progress has no intent colors; the credits meter is the only meter that escalates. */ .meter[data-intent='warning'] [data-slot='progress-indicator'] { background: var( --warning ); filter: saturate( 2 ) brightness( 1.8 ); } +/* eslint-disable-next-line studio/no-primitive-overrides -- Progress has no intent colors; the credits meter is the only meter that escalates. */ .meter[data-intent='critical'] [data-slot='progress-indicator'] { background: var( --warning ); filter: saturate( 1.6 ) brightness( 1.35 ); } +/* eslint-disable-next-line studio/no-primitive-overrides -- Progress has no intent colors; the credits meter is the only meter that escalates. */ .meter[data-intent='exhausted'] [data-slot='progress-indicator'] { background: var( --destructive ); } diff --git a/apps/ui/src/features/sidebar/site-list/style.module.css b/apps/ui/src/features/sidebar/site-list/style.module.css index 99acd89f89..27309ec338 100644 --- a/apps/ui/src/features/sidebar/site-list/style.module.css +++ b/apps/ui/src/features/sidebar/site-list/style.module.css @@ -70,6 +70,7 @@ } /* While several rows are selected, their hover actions step aside. */ +/* eslint-disable-next-line studio/no-primitive-overrides -- a multi-selection spans rows, so the list hides each row's actions. */ .selection[data-selecting] [data-slot='sidebar-menu-actions'] { visibility: hidden; } diff --git a/apps/ui/src/features/sites/add-site-dialog/blueprint-gallery.module.css b/apps/ui/src/features/sites/add-site-dialog/blueprint-gallery.module.css index dbb17564da..0b0ae2f758 100644 --- a/apps/ui/src/features/sites/add-site-dialog/blueprint-gallery.module.css +++ b/apps/ui/src/features/sites/add-site-dialog/blueprint-gallery.module.css @@ -85,6 +85,7 @@ color: var( --accent-text ); } +/* eslint-disable-next-line studio/no-primitive-overrides -- the whole card's hover tints the frame behind its thumbnail. */ .card:hover .thumbnail [data-slot='preview-frame'], .card:focus-visible .thumbnail [data-slot='preview-frame'] { background: var( --accent ); diff --git a/apps/ui/src/features/sites/site-preview/address-bar.module.css b/apps/ui/src/features/sites/site-preview/address-bar.module.css index bd05c77f8f..50f440a4cb 100644 --- a/apps/ui/src/features/sites/site-preview/address-bar.module.css +++ b/apps/ui/src/features/sites/site-preview/address-bar.module.css @@ -86,5 +86,6 @@ /* Rows rounded for the menu surface rather than the composer's. */ .shortcuts { + /* eslint-disable-next-line studio/no-primitive-overrides -- the suggestions sit on a menu surface, not the composer. */ --command-radius: var( --radius-md ); } diff --git a/eslint.config.mjs b/eslint.config.mjs index 95eafe6841..876a85b38e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -62,6 +62,26 @@ const UI_LAYERS = { ], }; +// Custom properties the primitives use, beyond the theme tokens. Feature CSS may +// set only the documented hooks (see apps/ui/src/lib/ui/README.md). +const cssCustomProperties = ( dir ) => + fs + .readdirSync( uiPath( dir ), { recursive: true } ) + .filter( ( file ) => file.endsWith( '.css' ) ) + .flatMap( + ( file ) => + fs.readFileSync( path.join( uiPath( dir ), file ), 'utf8' ).match( /--[\w-]+/g ) ?? [] + ); +const THEME_TOKENS = cssCustomProperties( 'lib/theme' ); +const PRIMITIVE_HOOKS = [ + '--button-background', + '--button-hover-background', + '--button-hover-color', +]; +const PRIMITIVE_PROPERTIES = [ ...new Set( cssCustomProperties( 'lib/ui' ) ) ].filter( + ( property ) => ! THEME_TOKENS.includes( property ) && ! PRIMITIVE_HOOKS.includes( property ) +); + export default defineConfig( globalIgnores( [ '**/node_modules/', @@ -253,7 +273,18 @@ export default defineConfig( // primitives in `src/lib/ui/`, which stay independent of app code. files: [ 'apps/ui/src/**/*.{ts,tsx}' ], rules: { - 'no-restricted-imports': [ 'error', { patterns: [ WP_UI_PACKAGES ] } ], + 'no-restricted-imports': [ + 'error', + { + patterns: [ + WP_UI_PACKAGES, + { + group: [ '@base-ui/react', '@base-ui/react/*' ], + message: 'Use the primitives in @/lib/ui, and add one there when none fits.', + }, + ], + }, + ], 'import-x/no-restricted-paths': [ 'error', UI_LAYERS ], 'import-x/no-cycle': 'error', }, @@ -290,6 +321,22 @@ export default defineConfig( rules: { 'prettier/prettier': 'error', 'studio/use-theme-tokens': 'error', + 'css/no-important': 'error', + }, + }, + { + files: [ 'apps/ui/src/**/*.css' ], + ignores: [ 'apps/ui/src/lib/**' ], + rules: { + 'studio/no-primitive-overrides': [ 'error', { properties: PRIMITIVE_PROPERTIES } ], + }, + }, + { + // The reduced-transparency reset has to beat class-scoped rules, and Shiki + // colors code tokens with inline styles. + files: [ 'apps/ui/src/lib/theme/base.css', 'apps/ui/src/lib/ui/code-block/style.module.css' ], + rules: { + 'css/no-important': 'off', }, }, { diff --git a/tools/eslint-plugin-studio/src/index.js b/tools/eslint-plugin-studio/src/index.js index 432da71048..4fc7cf7da2 100644 --- a/tools/eslint-plugin-studio/src/index.js +++ b/tools/eslint-plugin-studio/src/index.js @@ -1,4 +1,5 @@ import noModuleLevelTranslations from './rules/no-module-level-translations.js'; +import noPrimitiveOverrides from './rules/no-primitive-overrides.js'; import requireLockBeforeSave from './rules/require-lock-before-save.js'; import useThemeTokens from './rules/use-theme-tokens.js'; @@ -6,6 +7,7 @@ export default { rules: { 'require-lock-before-save': requireLockBeforeSave, 'no-module-level-translations': noModuleLevelTranslations, + 'no-primitive-overrides': noPrimitiveOverrides, 'use-theme-tokens': useThemeTokens, }, }; diff --git a/tools/eslint-plugin-studio/src/rules/no-primitive-overrides.js b/tools/eslint-plugin-studio/src/rules/no-primitive-overrides.js new file mode 100644 index 0000000000..236b890f72 --- /dev/null +++ b/tools/eslint-plugin-studio/src/rules/no-primitive-overrides.js @@ -0,0 +1,37 @@ +/** @type {import('eslint').Rule.RuleModule} */ +export default { + meta: { + type: 'suggestion', + docs: { + description: + "Keep feature CSS out of the UI primitives' internals: their parts and private custom properties.", + }, + schema: [ + { + type: 'object', + properties: { properties: { type: 'array', items: { type: 'string' } } }, + additionalProperties: false, + }, + ], + messages: { + part: "Don't style a primitive's parts through `[data-slot]`. Add a prop or variant to the primitive, or fix it there for every caller.", + property: + "`{{ property }}` is private to a primitive. Use a variant, or one of the primitive's documented hooks.", + }, + }, + create( context ) { + const properties = new Set( context.options[ 0 ]?.properties ?? [] ); + return { + Rule( node ) { + if ( /\[\s*data-slot\b/.test( context.sourceCode.getText( node.prelude ) ) ) { + context.report( { node: node.prelude, messageId: 'part' } ); + } + }, + Declaration( node ) { + if ( properties.has( node.property ) ) { + context.report( { node, messageId: 'property', data: { property: node.property } } ); + } + }, + }; + }, +}; diff --git a/tools/eslint-plugin-studio/tests/no-primitive-overrides.test.ts b/tools/eslint-plugin-studio/tests/no-primitive-overrides.test.ts new file mode 100644 index 0000000000..9fbafd4191 --- /dev/null +++ b/tools/eslint-plugin-studio/tests/no-primitive-overrides.test.ts @@ -0,0 +1,34 @@ +import css from '@eslint/css'; +import { RuleTester } from 'eslint'; +import { describe } from 'vitest'; +import rule from '../src/rules/no-primitive-overrides'; + +const ruleTester = new RuleTester( { + plugins: { css }, + language: 'css/css', + languageOptions: { tolerant: true }, +} ); + +const options = [ { properties: [ '--button-size' ] } ]; + +describe( 'no-primitive-overrides', () => { + ruleTester.run( 'no-primitive-overrides', rule, { + valid: [ + { code: '.a { --button-background: var( --muted ); margin: 0; }', options }, + { code: '.a[data-state="open"] { opacity: 0.7; }', options }, + ], + invalid: [ + { code: '.a { --button-size: 24px; }', options, errors: [ { messageId: 'property' } ] }, + { + code: ".a [data-slot='field-label'] { gap: 0; }", + options, + errors: [ { messageId: 'part' } ], + }, + { + code: ":global( [data-slot='empty'] ) > .a { margin: 0; }", + options, + errors: [ { messageId: 'part' } ], + }, + ], + } ); +} );