diff --git a/.changeset/mosaic-eslint-policy.md b/.changeset/mosaic-eslint-policy.md new file mode 100644 index 00000000000..6a4aeeed4d9 --- /dev/null +++ b/.changeset/mosaic-eslint-policy.md @@ -0,0 +1,5 @@ +--- +'@clerk/mosaic': patch +--- + +Use logical block spacing for feedback messages so their spacing follows the writing mode. diff --git a/eslint.config.mjs b/eslint.config.mjs index 066aa4a553c..42aa5a3a905 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -21,6 +21,7 @@ import globals from 'globals'; import tseslint from 'typescript-eslint'; import { CUSTOM_BLOCK_TAGS, CUSTOM_MODIFIER_TAGS } from './.typedoc/custom-tags.mjs'; +import mosaicQualityConfig from './packages/mosaic/eslint/quality-config.mjs'; const REPO_ROOT = import.meta.dirname; const ECMA_VERSION = 2021, @@ -589,6 +590,7 @@ export default tseslint.config([ }, }, ...PACKAGE_IMPORT_RESOLVER_CONFIGS, + ...mosaicQualityConfig, { name: 'packages/clerk-js', files: ['packages/clerk-js/src/ui/**/*'], diff --git a/packages/mosaic/eslint/code-quality.mjs b/packages/mosaic/eslint/code-quality.mjs new file mode 100644 index 00000000000..ae42564a263 --- /dev/null +++ b/packages/mosaic/eslint/code-quality.mjs @@ -0,0 +1,219 @@ +function variableAt(sourceCode, node) { + let scope = sourceCode.getScope(node); + while (scope) { + const variable = scope.set.get(node.name); + if (variable) { + return variable; + } + scope = scope.upper; + } + return null; +} + +function memberName(node) { + if (node.type !== 'MemberExpression') { + return null; + } + if (!node.computed && node.property.type === 'Identifier') { + return node.property.name; + } + if (node.computed && node.property.type === 'Literal') { + return node.property.value; + } + return null; +} + +function accumulatorFor(context, node) { + const sourceCode = context.sourceCode; + for (const ancestor of sourceCode.getAncestors(node).reverse()) { + if ( + ancestor.type !== 'ArrowFunctionExpression' && + ancestor.type !== 'FunctionExpression' && + ancestor.type !== 'FunctionDeclaration' + ) { + continue; + } + const call = ancestor.parent; + if (call?.type !== 'CallExpression' || call.arguments[0] !== ancestor || memberName(call.callee) !== 'reduce') { + return null; + } + const parameter = ancestor.params[0]; + if (parameter?.type !== 'Identifier') { + return null; + } + return variableAt(sourceCode, parameter); + } + return null; +} + +function isAccumulator(context, node) { + const accumulator = node?.type === 'Identifier' ? accumulatorFor(context, node) : null; + return accumulator !== null && variableAt(context.sourceCode, node) === accumulator; +} + +function isBuiltin(sourceCode, node) { + const variable = variableAt(sourceCode, node); + return variable === null || variable.defs.length === 0; +} + +export const noAccumulatorCopy = { + meta: { type: 'suggestion', schema: [], messages: { copy: 'Avoid copying a reduce accumulator on each iteration.' } }, + create(context) { + return { + SpreadElement(node) { + if ( + (node.parent.type === 'ArrayExpression' || node.parent.type === 'ObjectExpression') && + isAccumulator(context, node.argument) + ) { + context.report({ node, messageId: 'copy' }); + } + }, + CallExpression(node) { + const name = memberName(node.callee); + if ((name === 'concat' || name === 'slice') && isAccumulator(context, node.callee.object)) { + context.report({ node, messageId: 'copy' }); + } + if ( + name === 'from' && + node.callee.object.type === 'Identifier' && + node.callee.object.name === 'Array' && + isBuiltin(context.sourceCode, node.callee.object) && + isAccumulator(context, node.arguments[0]) + ) { + context.report({ node, messageId: 'copy' }); + } + if ( + name === 'assign' && + node.callee.object.type === 'Identifier' && + node.callee.object.name === 'Object' && + isBuiltin(context.sourceCode, node.callee.object) && + node.arguments[0]?.type === 'ObjectExpression' && + node.arguments[0].properties.length === 0 && + isAccumulator(context, node.arguments[1]) + ) { + context.report({ node, messageId: 'copy' }); + } + }, + }; + }, +}; + +export const noChainedTypeAssertions = { + meta: { type: 'suggestion', schema: [], messages: { chain: 'Avoid chaining type assertions.' } }, + create(context) { + function check(node) { + if ( + node.parent?.expression === node && + (node.parent.type === 'TSAsExpression' || node.parent.type === 'TSTypeAssertion') + ) { + return; + } + if (node.expression.type === 'TSAsExpression' || node.expression.type === 'TSTypeAssertion') { + context.report({ node, messageId: 'chain' }); + } + } + return { TSAsExpression: check, TSTypeAssertion: check }; + }, +}; + +function annotation(node) { + return node.typeAnnotation?.typeAnnotation ?? null; +} + +function isWide(node, sourceCode) { + if (!node) { + return false; + } + if (node.type === 'TSUnknownKeyword' || node.type === 'TSObjectKeyword') { + return true; + } + return node.type === 'TSTypeReference' && sourceCode.getText(node).replaceAll(/\s/g, '') === 'Record'; +} + +function functionBoundary(sourceCode, node) { + return ( + sourceCode + .getAncestors(node) + .reverse() + .find( + ancestor => + ancestor.type === 'FunctionDeclaration' || + ancestor.type === 'FunctionExpression' || + ancestor.type === 'ArrowFunctionExpression', + ) ?? null + ); +} + +function typeKey(sourceCode, node) { + return sourceCode + .getTokens(node) + .map(token => token.value) + .join(''); +} + +export const noWidenThenAssert = { + meta: { + type: 'suggestion', + schema: [], + messages: { widen: 'Avoid widening a known type before asserting it back.' }, + }, + create(context) { + const sourceCode = context.sourceCode; + const known = new Map(); + const widened = new Map(); + function checkAssertion(node) { + if (node.expression.type !== 'Identifier') { + return; + } + const type = widened.get(variableAt(sourceCode, node.expression)); + if ( + type?.boundary === functionBoundary(sourceCode, node) && + typeKey(sourceCode, node.typeAnnotation) === type.type + ) { + context.report({ node, messageId: 'widen' }); + } + } + return { + ':function'(node) { + for (const param of node.params) { + if (param.type === 'Identifier' && annotation(param) && !isWide(annotation(param), sourceCode)) { + known.set(variableAt(sourceCode, param), { type: typeKey(sourceCode, annotation(param)), boundary: node }); + } + } + }, + VariableDeclarator(node) { + if (node.id.type !== 'Identifier' || node.parent.kind !== 'const') { + return; + } + const variable = variableAt(sourceCode, node.id); + const type = annotation(node.id); + if (type && !isWide(type, sourceCode)) { + known.set(variable, { type: typeKey(sourceCode, type), boundary: functionBoundary(sourceCode, node) }); + } + const assertion = + node.init?.type === 'TSAsExpression' || node.init?.type === 'TSTypeAssertion' ? node.init : null; + const initializer = assertion ? assertion.expression : node.init; + const widenedType = type ?? assertion?.typeAnnotation; + if (initializer?.type !== 'Identifier') { + return; + } + const origin = variableAt(sourceCode, initializer); + if (isWide(widenedType, sourceCode) && known.get(origin)?.boundary === functionBoundary(sourceCode, node)) { + widened.set(variable, known.get(origin)); + } else if (!widenedType && widened.has(origin)) { + widened.set(variable, widened.get(origin)); + } + }, + TSAsExpression: checkAssertion, + TSTypeAssertion: checkAssertion, + }; + }, +}; + +export default { + rules: { + 'no-accumulator-copy': noAccumulatorCopy, + 'no-chained-type-assertions': noChainedTypeAssertions, + 'no-widen-then-assert': noWidenThenAssert, + }, +}; diff --git a/packages/mosaic/eslint/code-quality.test.mjs b/packages/mosaic/eslint/code-quality.test.mjs new file mode 100644 index 00000000000..291103003cf --- /dev/null +++ b/packages/mosaic/eslint/code-quality.test.mjs @@ -0,0 +1,67 @@ +import { RuleTester } from 'eslint'; +import tseslint from 'typescript-eslint'; +import { describe, test } from 'vitest'; + +import { noAccumulatorCopy, noChainedTypeAssertions, noWidenThenAssert } from './code-quality.mjs'; + +RuleTester.describe = describe; + +RuleTester.it = test; + +RuleTester.itOnly = test.only; + +const js = new RuleTester(); + +const ts = new RuleTester({ languageOptions: { parser: tseslint.parser } }); + +js.run('no-accumulator-copy', noAccumulatorCopy, { + valid: [ + 'const result = values.reduce((acc, value) => { acc.push(value); return acc; }, []);', + 'const copy = [...acc];', + 'values.reduce((acc, value) => { consume(...acc); acc.push(value); return acc; }, []);', + 'const result = values.reduce((acc, value) => { const nested = other.map(acc => [...acc]); acc.push(value); return acc; }, []);', + 'const result = values.reduce((acc, value) => { const nested = other.map(() => [...value]); acc.push(value); return acc; }, []);', + 'const result = values.reduce((acc, value) => { async function later() { return [...acc]; } acc.push(value); return acc; }, []);', + 'function f(Object, Array) { return values.reduce((acc, value) => Object.assign({}, acc, value) || Array.from(acc), []); }', + ], + invalid: [ + { code: 'values.reduce((acc, value) => [...acc, value], [])', errors: 1 }, + { code: 'values.reduce(function(acc, value) { return Object.assign({}, acc, value); }, {})', errors: 1 }, + { code: 'values.reduce((acc, value) => Array.from(acc), [])', errors: 1 }, + { code: 'values.reduce((acc, value) => acc.concat(value), [])', errors: 1 }, + { code: 'values.reduce((acc, value) => acc.slice(), [])', errors: 1 }, + ], +}); + +ts.run('no-chained-type-assertions', noChainedTypeAssertions, { + valid: ['const x = value as const;', 'const x = value as unknown;', 'const x = (value as const) satisfies Shape;'], + invalid: [ + { code: 'const x = value as unknown as Foo;', errors: 1 }, + { code: 'const x = ({ x: 1 } as const) as Foo;', errors: 1 }, + { code: 'const x = value as unknown as Foo as Bar;', errors: 1 }, + { code: 'const x = value;', errors: 1 }, + ], +}); + +ts.run('no-widen-then-assert', noWidenThenAssert, { + valid: [ + 'const source: Foo = make(); const wide: unknown = source; const result = other as Foo;', + 'const source: Foo = make(); let wide: unknown = source; const result = wide as Foo;', + 'const source: Foo = make(); const wide: unknown = source; const result = (() => { const wide = other; return wide as Foo; })();', + 'const source: Foo = make(); const wide: unknown = source; const result = wide as Bar;', + 'const source: Foo = make(); function f() { const wide: unknown = source; return wide as Foo; }', + ], + invalid: [ + { code: 'const source: Foo = make(); const wide: unknown = source; const result = wide as Foo;', errors: 1 }, + { code: 'const source: Foo = make(); const wide = source as unknown; const result = wide as Foo;', errors: 1 }, + { code: 'function f(source: Foo) { const wide = source; return wide; }', errors: 1 }, + { + code: 'function f(source: Foo) { const wide: Record = source; return wide as Foo; }', + errors: 1, + }, + { + code: 'const source: Foo = make(); const wide: object = source; const alias = wide; const result = alias as Foo;', + errors: 1, + }, + ], +}); diff --git a/packages/mosaic/eslint/good-css.mjs b/packages/mosaic/eslint/good-css.mjs new file mode 100644 index 00000000000..efc88935a3d --- /dev/null +++ b/packages/mosaic/eslint/good-css.mjs @@ -0,0 +1,406 @@ +function propertyName(node, sourceCode) { + if (node.type !== 'Property') { + return null; + } + if (node.computed) { + if (node.key.type !== 'Identifier' || !sourceCode) { + return null; + } + const variable = variableAt(sourceCode, node.key); + if (variable?.defs.length !== 1 || variable.defs[0].parent?.kind !== 'const') { + return null; + } + return variable.defs[0].node.init ? valueText(variable.defs[0].node.init) : null; + } + if (node.key.type === 'Identifier') { + return node.key.name; + } + if (node.key.type === 'Literal') { + return String(node.key.value); + } + return null; +} + +function valueText(node) { + if (node.type === 'Literal' && (typeof node.value === 'string' || typeof node.value === 'number')) { + return String(node.value); + } + if (node.type === 'TemplateLiteral' && node.expressions.length === 0) { + return node.quasis[0].value.cooked; + } + return null; +} + +function entries(node, sourceCode) { + if (node?.type !== 'ObjectExpression') { + return []; + } + return node.properties.filter( + property => property.type === 'Property' && propertyName(property, sourceCode) !== null, + ); +} + +function entry(node, name) { + return entries(node).find(property => propertyName(property) === name); +} + +function variableAt(sourceCode, node) { + let scope = sourceCode.getScope(node); + while (scope) { + const variable = scope.set.get(node.name); + if (variable) { + return variable; + } + scope = scope.upper; + } + return null; +} + +function isStyleCall(node, imports, sourceCode) { + if ( + node.callee.type === 'MemberExpression' && + !node.callee.computed && + node.callee.property.name === 'create' && + node.callee.object.type === 'Identifier' + ) { + return imports.namespaces.has(variableAt(sourceCode, node.callee.object)); + } + return node.callee.type === 'Identifier' && imports.functions.has(variableAt(sourceCode, node.callee)); +} + +function styleObjects(context, visit) { + const imports = { namespaces: new Set(), functions: new Set() }; + return { + ImportDeclaration(node) { + const source = node.source.value; + for (const specifier of node.specifiers) { + const variable = variableAt(context.sourceCode, specifier.local); + if (source === '@stylexjs/stylex' && specifier.type === 'ImportNamespaceSpecifier') { + imports.namespaces.add(variable); + } + if ( + source === '@stylexjs/stylex' && + specifier.type === 'ImportSpecifier' && + specifier.imported.name === 'create' + ) { + imports.functions.add(variable); + } + } + }, + CallExpression(node) { + if (!isStyleCall(node, imports, context.sourceCode)) { + return; + } + const root = node.arguments[0]; + if (root?.type !== 'ObjectExpression') { + return; + } + for (const variant of entries(root)) { + const value = variant.value; + if (value.type === 'ArrowFunctionExpression' || value.type === 'FunctionExpression') { + if (value.body.type === 'ObjectExpression') { + visitStyle(value.body, visit); + } + if ( + value.body.type === 'BlockStatement' && + value.body.body.length === 1 && + value.body.body[0].type === 'ReturnStatement' + ) { + visitStyle(value.body.body[0].argument, visit); + } + } else { + visitStyle(value, visit); + } + } + }, + }; +} + +function visitStyle(object, visit, conditions = []) { + if (object?.type !== 'ObjectExpression') { + return; + } + visit(object, conditions); + for (const property of entries(object)) { + const name = propertyName(property); + if ( + (name.startsWith(':') || name.startsWith('&') || name.startsWith('@media')) && + property.value.type === 'ObjectExpression' + ) { + visitStyle(property.value, visit, [...conditions, name]); + } + } +} + +function conditionalLeaves(node, conditions = [], sourceCode) { + if (node.type !== 'ObjectExpression') { + return [{ node, conditions }]; + } + return entries(node, sourceCode).flatMap(property => + conditionalLeaves(property.value, [...conditions, propertyName(property, sourceCode)], sourceCode), + ); +} + +function isPreference(conditions, preference) { + return conditions.some(condition => { + if (!condition.startsWith('@media ') || /,|\bor\b|\bnot\b/i.test(condition)) { + return false; + } + return condition + .slice(7) + .split(/\s+and\s+/i) + .some(part => part.trim() === `(prefers-reduced-motion: ${preference})`); + }); +} + +function conditionParts(conditions) { + return conditions.flatMap(condition => + condition === 'default' + ? [] + : condition.startsWith('@media ') && !/,|\bor\b|\bnot\b/i.test(condition) + ? condition + .slice(7) + .split(/\s+and\s+/i) + .map(part => `@media ${part.trim()}`) + : [condition], + ); +} + +function covers(override, target, feature) { + const overrideParts = conditionParts(override); + const targetParts = conditionParts(target); + if (!overrideParts.includes(`@media (${feature})`)) { + return false; + } + return overrideParts.filter(part => part !== `@media (${feature})`).every(part => targetParts.includes(part)); +} + +function selectorPath(conditions) { + return conditions.filter(condition => condition !== 'default' && !condition.startsWith('@media')); +} + +function splitTransitions(text) { + const parts = []; + let depth = 0; + let start = 0; + for (let index = 0; index < text.length; index++) { + if (text[index] === '(') { + depth++; + } + if (text[index] === ')') { + depth--; + } + if (text[index] === ',' && depth === 0) { + parts.push(text.slice(start, index).trim()); + start = index + 1; + } + } + parts.push(text.slice(start).trim()); + return parts; +} + +function transitionProperty(part) { + const tokens = part.split(/\s+(?![^()]*\))/); + return tokens.find( + token => + !/^(?:-?\d*\.?\d+m?s|ease(?:-in|-out|-in-out)?|linear|step-start|step-end|steps\(|cubic-bezier\(|allow-discrete$|normal$)/i.test( + token, + ), + ); +} + +function hasMovement(text, shorthand = false) { + if (!text) { + return false; + } + const properties = splitTransitions(text).map(part => (shorthand ? transitionProperty(part) : part.trim())); + return properties.some(property => + /^(?:all|transform|translate|rotate|scale|width|height|inline-size|block-size|min-width|max-width|min-height|max-height)$/i.test( + property ?? '', + ), + ); +} + +function zeroTransition(text) { + return ( + text !== null && + splitTransitions(text).every(part => { + const duration = part.match(/(?:^|\s)(-?\d*\.?\d+m?s)(?=\s|$)/i); + return duration && /^0(?:\.0+)?m?s$/i.test(duration[1]); + }) + ); +} + +const transitionMeta = { type: 'suggestion', schema: [] }; + +export const explicitTransitionProperties = { + meta: { ...transitionMeta, messages: { explicit: 'Name the transition properties explicitly.' } }, + create(context) { + return styleObjects(context, style => { + const properties = entry(style, 'transitionProperty'); + if ( + properties && + conditionalLeaves(properties.value, [], context.sourceCode).some(leaf => + splitTransitions(valueText(leaf.node) ?? '').includes('all'), + ) + ) { + context.report({ node: properties, messageId: 'explicit' }); + } + for (const property of entries(style)) { + if (propertyName(property) !== 'transition') { + continue; + } + if ( + conditionalLeaves(property.value, [], context.sourceCode).some(leaf => { + const text = valueText(leaf.node); + return ( + text && + splitTransitions(text).some( + part => + !/^(?:none|initial|inherit|unset|revert|revert-layer)$/i.test(part) && + (!transitionProperty(part) || transitionProperty(part) === 'all'), + ) + ); + }) + ) { + context.report({ node: property, messageId: 'explicit' }); + } + } + }); + }, +}; + +export const reducedMotion = { + meta: { + ...transitionMeta, + messages: { motion: 'Gate movement transitions behind no-preference or remove them under reduced motion.' }, + }, + create(context) { + return styleObjects(context, (style, outerConditions) => { + for (const property of entries(style)) { + const name = propertyName(property); + if (name !== 'transition' && name !== 'transitionProperty') { + continue; + } + const shorthand = name === 'transition'; + const leaves = conditionalLeaves(property.value, [], context.sourceCode); + const reducedLeaves = leaves.filter(leaf => isPreference([...outerConditions, ...leaf.conditions], 'reduce')); + const duration = entry(style, 'transitionDuration'); + const durationLeaves = duration ? conditionalLeaves(duration.value, [], context.sourceCode) : []; + const unsafe = leaves.some(leaf => { + const text = valueText(leaf.node); + if (!hasMovement(text, shorthand) || (shorthand && zeroTransition(text))) { + return false; + } + const conditions = [...outerConditions, ...leaf.conditions]; + if (isPreference(conditions, 'no-preference')) { + return false; + } + if (isPreference(conditions, 'reduce')) { + return true; + } + if ( + reducedLeaves.some( + reduced => + valueText(reduced.node) !== null && + covers([...outerConditions, ...reduced.conditions], conditions, 'prefers-reduced-motion: reduce') && + (!hasMovement(valueText(reduced.node), shorthand) || + (shorthand && zeroTransition(valueText(reduced.node)))), + ) + ) { + return false; + } + if ( + durationLeaves.some( + reduced => + covers([...outerConditions, ...reduced.conditions], conditions, 'prefers-reduced-motion: reduce') && + /^0(?:\.0+)?m?s$/.test(valueText(reduced.node) ?? ''), + ) + ) { + return false; + } + return true; + }); + if (unsafe) { + context.report({ node: property, messageId: 'motion' }); + } + } + }); + }, +}; + +function isRemoved(node) { + const text = valueText(node); + return text !== null && /^(?:none|0(?:px)?(?:\s+.*)?)$/i.test(text.trim()); +} + +function visibleOutline(node) { + const text = valueText(node); + return text !== null && !isRemoved(node) && !/^(?:transparent|hidden|dotted\s+transparent)$/i.test(text.trim()); +} + +function isFocusSelector(name) { + return /^(?:&)?:(?:focus|focus-visible)(?![\w-])/.test(name); +} + +export const preserveFocusOutline = { + meta: { + ...transitionMeta, + messages: { focus: 'Keep a visible outline for keyboard focus, including forced colors.' }, + }, + create(context) { + return styleObjects(context, (style, outerConditions) => { + for (const property of entries(style)) { + const name = propertyName(property); + if (name === 'outline') { + const leaves = conditionalLeaves(property.value, [], context.sourceCode); + for (const leaf of leaves) { + const conditions = [...outerConditions, ...leaf.conditions]; + if (!conditions.some(isFocusSelector) || !isRemoved(leaf.node)) { + continue; + } + const width = entry(style, 'outlineWidth'); + const outlineStyle = entry(style, 'outlineStyle'); + if (width && visibleOutline(width.value) && outlineStyle && visibleOutline(outlineStyle.value)) { + continue; + } + const restored = leaves.some( + candidate => + visibleOutline(candidate.node) && + covers([...outerConditions, ...candidate.conditions], conditions, 'forced-colors: active') && + selectorPath(candidate.conditions).every(selector => selectorPath(leaf.conditions).includes(selector)), + ); + if (!restored) { + context.report({ node: leaf.node, messageId: 'focus' }); + break; + } + } + } + } + }); + }, +}; + +const physicalSpacing = new Set(['marginTop', 'marginBottom', 'paddingTop', 'paddingBottom']); + +export const logicalSpacing = { + meta: { ...transitionMeta, messages: { logical: 'Use the block-axis logical spacing property.' } }, + create(context) { + return styleObjects(context, style => { + for (const property of entries(style)) { + if (physicalSpacing.has(propertyName(property))) { + context.report({ node: property, messageId: 'logical' }); + } + } + }); + }, +}; + +export default { + rules: { + 'explicit-transition-properties': explicitTransitionProperties, + 'reduced-motion': reducedMotion, + 'preserve-focus-outline': preserveFocusOutline, + 'logical-spacing': logicalSpacing, + }, +}; diff --git a/packages/mosaic/eslint/good-css.test.mjs b/packages/mosaic/eslint/good-css.test.mjs new file mode 100644 index 00000000000..8051463247b --- /dev/null +++ b/packages/mosaic/eslint/good-css.test.mjs @@ -0,0 +1,159 @@ +import { RuleTester } from 'eslint'; +import tseslint from 'typescript-eslint'; +import { describe, test } from 'vitest'; + +import { explicitTransitionProperties, logicalSpacing, preserveFocusOutline, reducedMotion } from './good-css.mjs'; + +RuleTester.describe = describe; + +RuleTester.it = test; + +RuleTester.itOnly = test.only; + +const tester = new RuleTester({ + languageOptions: { parser: tseslint.parser, ecmaVersion: 'latest', sourceType: 'module' }, +}); + +const stylex = "import * as sx from '@stylexjs/stylex'; "; + +const named = "import { create as make } from '@stylexjs/stylex'; "; + +tester.run('explicit-transition-properties', explicitTransitionProperties, { + valid: [ + `${stylex}sx.create({ a: { transition: '200ms opacity' } });`, + `${named}make({ a: { transition: 'transform 150ms ease, opacity 200ms' } });`, + `${stylex}sx.create({ a: { transitionDuration: '200ms', transitionProperty: 'opacity' } });`, + `${stylex}sx.create({ a: { transitionDuration: '200ms' } });`, + `${stylex}sx.create({ a: { transition: 'opacity 200ms allow-discrete' } });`, + `${stylex}sx.create({ a: { transition: 'opacity 200ms -100ms' } });`, + `${stylex}sx.create({ a: { transition: '200ms -100ms transform' } });`, + `${stylex}sx.create({ a: { transition: 'none', transitionProperty: 'inherit' } });`, + 'const obj = { transition: "all 200ms" };', + `${stylex}sx.create({ a: { transition: variable } });`, + `${stylex}function f(sx) { return sx.create({ a: { transition: 'all 200ms' } }); }`, + `${stylex}sx.create({ a: { transitionProperty: { default: 'opacity', ':hover': 'color' } } });`, + ], + invalid: [ + { code: `${stylex}sx.create({ a: { transition: 'all 200ms' } });`, errors: 1 }, + { code: `${stylex}sx.create({ a: { transition: '200ms ease' } });`, errors: 1 }, + { code: `${stylex}sx.create({ a: { transition: '200ms allow-discrete' } });`, errors: 1 }, + { code: `${stylex}sx.create({ a: { transition: '200ms -100ms' } });`, errors: 1 }, + { code: `${stylex}sx.create({ a: value => ({ transition: 'all 200ms' }) });`, errors: 1 }, + { code: `${stylex}sx.create({ a: { transitionProperty: 'all' } });`, errors: 1 }, + { code: `${stylex}sx.create({ a: { transitionProperty: { default: 'opacity', ':hover': 'all' } } });`, errors: 1 }, + { + code: `${stylex}sx.create({ a: { transition: { default: 'opacity 200ms', '@media (prefers-reduced-motion: no-preference)': 'all 1s' } } });`, + errors: 1, + }, + { + code: `${stylex}sx.create({ a: { transition: 'opacity 200ms cubic-bezier(0.2, 0, 0.5, 1), all 300ms' } });`, + errors: 1, + }, + ], +}); + +tester.run('reduced-motion', reducedMotion, { + valid: [ + `${stylex}sx.create({ a: { transitionProperty: { default: 'transform, width', '@media (prefers-reduced-motion: reduce)': 'opacity' } } });`, + `${stylex}sx.create({ a: { transitionProperty: { ':hover': 'transform', '@media (prefers-reduced-motion: reduce)': { ':hover': 'none' } } } });`, + `${stylex}sx.create({ a: { transitionProperty: 'transform', transitionDuration: { default: '200ms', '@media (prefers-reduced-motion: reduce)': '0ms' } } });`, + `${stylex}sx.create({ a: { transitionProperty: { default: 'none', '@media (prefers-reduced-motion: no-preference)': 'transform' } } });`, + `${stylex}sx.create({ a: { transitionProperty: { default: 'none', '@media (prefers-reduced-motion: no-preference) and (min-width: 800px)': 'transform' } } });`, + `${stylex}sx.create({ a: { transitionProperty: 'opacity, color' } });`, + `${stylex}sx.create({ a: { transitionProperty: { default: 'transform', '@media (prefers-reduced-motion: reduce)': 'none' } } });`, + `${stylex}const reduceMotion = '@media (prefers-reduced-motion: reduce)'; sx.create({ a: { transitionProperty: { default: 'transform', [reduceMotion]: 'none' } } });`, + `${stylex}sx.create({ a: { transitionProperty: { '@media (min-width: 800px)': 'transform', '@media (prefers-reduced-motion: reduce)': 'none' } } });`, + `${stylex}sx.create({ a: { transition: { default: 'transform 200ms', '@media (prefers-reduced-motion: reduce)': 'none' } } });`, + `${stylex}sx.create({ a: { transition: { default: 'transform 200ms', '@media (prefers-reduced-motion: reduce)': 'transform 0ms' } } });`, + `${stylex}sx.create({ a: { transition: 'transform 200ms', transitionDuration: { default: '200ms', '@media (prefers-reduced-motion: reduce)': '0ms' } } });`, + `${stylex}sx.create({ a: { transitionProperty: { default: 'transform', '@media (prefers-reduced-motion: reduce)': 'none' }, transitionDuration: '200ms' } });`, + 'const obj = { transitionProperty: "transform" };', + ], + invalid: [ + { code: `${stylex}sx.create({ a: { transitionProperty: 'transform' } });`, errors: 1 }, + { + code: `${stylex}sx.create({ a: { transition: { default: 'transform 200ms', '@media (prefers-reduced-motion: reduce)': 'transform 200ms 0ms' } } });`, + errors: 1, + }, + { + code: `${stylex}sx.create({ a: { transitionProperty: { default: 'transform', '@media (prefers-reduced-motion: reduce)': dynamicOverride } } });`, + errors: 1, + }, + { code: `${stylex}sx.create({ a: value => { return { transition: 'transform 200ms' }; } });`, errors: 1 }, + { + code: `${stylex}sx.create({ a: { transitionProperty: { default: 'transform', '@media (prefers-reduced-motion: reduce)': { '@media (min-width: 800px)': 'none' } } } });`, + errors: 1, + }, + { + code: `${stylex}sx.create({ a: { transitionProperty: { default: 'transform', [unknownCondition]: 'none' } } });`, + errors: 1, + }, + { + code: `${stylex}sx.create({ a: { transitionProperty: { default: 'transform', '@media (prefers-reduced-motion: no-preference) or (min-width: 800px)': 'transform' } } });`, + errors: 1, + }, + { + code: `${stylex}sx.create({ a: { transitionProperty: { default: 'transform', '@media (prefers-reduced-motion: reduce)': 'transform' } } });`, + errors: 1, + }, + { code: `${stylex}sx.create({ a: { transition: 'transform 200ms' } });`, errors: 1 }, + { code: `${stylex}sx.create({ a: { transitionProperty: { ':hover': 'transform' } } });`, errors: 1 }, + { + code: `${stylex}sx.create({ a: { transitionProperty: { ':hover': 'transform', '@media (prefers-reduced-motion: reduce)': { ':active': 'none' } } } });`, + errors: 1, + }, + { + code: `${stylex}sx.create({ a: { transitionProperty: { default: 'transform', '@media not (prefers-reduced-motion: no-preference)': 'none' } } });`, + errors: 1, + }, + { + code: `${stylex}sx.create({ a: { transitionProperty: { default: 'transform', '@media (prefers-reduced-motion: reduce), (min-width: 10px)': 'none' } } });`, + errors: 1, + }, + ], +}); + +tester.run('preserve-focus-outline', preserveFocusOutline, { + valid: [ + `${stylex}sx.create({ a: { outline: 'none' } });`, + `${stylex}sx.create({ a: { outline: { ':focus-within': 'none', ':not(:focus)': 'none' } } });`, + `${stylex}sx.create({ a: { outline: { default: 'none', ':focus-visible': '2px solid blue' } } });`, + `${stylex}sx.create({ a: { outline: { default: 'none', ':focus-visible': 'none', '@media (forced-colors: active)': '2px solid CanvasText' } } });`, + `${stylex}sx.create({ a: { ':focus-visible': { outline: 'none', outlineWidth: 2, outlineStyle: 'solid' } } });`, + `${stylex}sx.create({ a: { outline: { ':focus-visible': '2px solid transparent' } } });`, + `${stylex}sx.create({ a: { outline: { ':focus-visible': { default: 'none', '@media (forced-colors: active)': '2px solid CanvasText' } } } });`, + 'const obj = { outline: { ":focus-visible": "none" } };', + ], + invalid: [ + { + code: `${stylex}sx.create({ a: { outline: { default: '2px solid blue', ':focus-visible': 'none' } } });`, + errors: 1, + }, + { code: `${stylex}sx.create({ a: { ':focus-visible': { outline: 0, boxShadow: '0 0 2px blue' } } });`, errors: 1 }, + { + code: `${stylex}sx.create({ a: { outline: { ':focus-visible': { default: 'none', '@media (forced-colors: active)': 'none' } } } });`, + errors: 1, + }, + { code: `${stylex}sx.create({ a: { outline: { ':focus-visible': '0 solid transparent' } } });`, errors: 1 }, + { + code: `${stylex}sx.create({ a: { outline: { ':focus-visible': { default: 'none', '@media (forced-colors: active) and (min-width: 800px)': '2px solid CanvasText' } } } });`, + errors: 1, + }, + { + code: `${stylex}sx.create({ a: { ':focus-visible': { outline: 'none', outlineWidth: 0, outlineStyle: 'solid' } } });`, + errors: 1, + }, + ], +}); + +tester.run('logical-spacing', logicalSpacing, { + valid: [ + `${stylex}sx.create({ a: { paddingBlockStart: 4 } });`, + 'const obj = { marginTop: 4 };', + `${stylex}sx.create({ a: { transition: { marginTop: 4 } } });`, + ], + invalid: [ + { code: `${stylex}sx.create({ a: { marginTop: 4, paddingBottom: 8 } });`, errors: 2 }, + { code: `${stylex}sx.create({ a: { marginBottom: { default: 4, ':hover': 8 } } });`, errors: 1 }, + ], +}); diff --git a/packages/mosaic/eslint/quality-config.mjs b/packages/mosaic/eslint/quality-config.mjs new file mode 100644 index 00000000000..db281d59c83 --- /dev/null +++ b/packages/mosaic/eslint/quality-config.mjs @@ -0,0 +1,41 @@ +import tseslint from 'typescript-eslint'; + +import codeQuality from './code-quality.mjs'; +import goodCss from './good-css.mjs'; +const testFiles = ['**/__tests__/**', '**/*.test.*', '**/*.spec.*', '**/*.test-d.ts']; +export default [ + { + ...tseslint.configs.disableTypeChecked, + name: 'packages/mosaic - lint tooling', + files: ['packages/mosaic/eslint/*.mjs'], + }, + { + name: 'packages/mosaic - code quality', + files: ['packages/mosaic/src/**/*.{ts,tsx}'], + ignores: testFiles, + plugins: { 'code-quality': codeQuality }, + rules: { + 'code-quality/no-accumulator-copy': 'warn', + 'code-quality/no-chained-type-assertions': 'warn', + 'code-quality/no-widen-then-assert': 'warn', + }, + }, + { + name: 'packages/mosaic - internal conditions', + files: ['packages/mosaic/src/{utils,machine}/**/*.{ts,tsx}'], + ignores: testFiles, + rules: { '@typescript-eslint/no-unnecessary-condition': 'warn' }, + }, + { + name: 'packages/mosaic - css conventions', + files: ['packages/mosaic/src/**/*.styles.ts'], + ignores: testFiles, + plugins: { 'good-css': goodCss }, + rules: { + 'good-css/explicit-transition-properties': 'error', + 'good-css/reduced-motion': 'error', + 'good-css/preserve-focus-outline': 'error', + 'good-css/logical-spacing': 'warn', + }, + }, +]; diff --git a/packages/mosaic/eslint/quality-config.test.mjs b/packages/mosaic/eslint/quality-config.test.mjs new file mode 100644 index 00000000000..11cbe9fbf8e --- /dev/null +++ b/packages/mosaic/eslint/quality-config.test.mjs @@ -0,0 +1,59 @@ +import path from 'node:path'; + +import { ESLint } from 'eslint'; +import { describe, expect, test } from 'vitest'; + +const root = path.resolve(import.meta.dirname, '../../..'); + +const eslint = new ESLint({ cwd: root }); + +describe('ported lint policy', () => { + test('checks Mosaic implementation code without rejecting test assertions', async () => { + const implementation = await eslint.calculateConfigForFile('packages/mosaic/src/utils/apply-order.ts'); + const tests = await eslint.calculateConfigForFile('packages/mosaic/src/__tests__/object.test.ts'); + + for (const rule of ['no-accumulator-copy', 'no-chained-type-assertions', 'no-widen-then-assert']) { + expect(implementation.rules[`code-quality/${rule}`]?.[0]).toBe(1); + expect(tests.rules[`code-quality/${rule}`]).toBeUndefined(); + } + }); + + test('preserves hook ordering and the React 18 and 19 contract', async () => { + const config = await eslint.calculateConfigForFile('packages/mosaic/src/index.ts'); + + expect(config.rules['react-hooks/rules-of-hooks'][0]).toBe(2); + expect(config.rules['custom-rules/no-raw-inert'][0]).toBe(2); + expect(config.rules['react-hooks/refs']).toBeUndefined(); + }); + + test('leaves other packages untouched', async () => { + const config = await eslint.calculateConfigForFile('packages/ui/src/index.ts'); + + expect(config.rules['react-hooks/rules-of-hooks'][0]).toBe(1); + expect(config.rules['no-restricted-syntax']).toBeUndefined(); + expect(Object.keys(config.rules).filter(rule => /^(code-quality|good-css)\//.test(rule))).toEqual([]); + }); + + test('checks authored Mosaic styles despite the selector override', async () => { + const styles = await eslint.calculateConfigForFile('packages/mosaic/src/components/button/button.styles.ts'); + const component = await eslint.calculateConfigForFile('packages/mosaic/src/components/button/button.tsx'); + + for (const rule of [ + 'explicit-transition-properties', + 'reduced-motion', + 'preserve-focus-outline', + 'logical-spacing', + ]) { + expect(styles.rules[`good-css/${rule}`]?.[0]).toBeGreaterThan(0); + expect(component.rules[`good-css/${rule}`]).toBeUndefined(); + } + }); + + test('limits unnecessary-condition checks to internal Mosaic logic', async () => { + const internal = await eslint.calculateConfigForFile('packages/mosaic/src/utils/apply-order.ts'); + const boundary = await eslint.calculateConfigForFile('packages/shared/src/object.ts'); + + expect(internal.rules['@typescript-eslint/no-unnecessary-condition']?.[0]).toBe(1); + expect(boundary.rules['@typescript-eslint/no-unnecessary-condition']).toBeUndefined(); + }); +}); diff --git a/packages/mosaic/src/components/checkbox/checkbox.styles.ts b/packages/mosaic/src/components/checkbox/checkbox.styles.ts index 38c9a7babc2..11b5aaaab6a 100644 --- a/packages/mosaic/src/components/checkbox/checkbox.styles.ts +++ b/packages/mosaic/src/components/checkbox/checkbox.styles.ts @@ -82,8 +82,8 @@ export const sizes = stylex.create({ }); export const firstLine = stylex.create({ - sm: { marginTop: `max(0px, calc((1lh - ${space['3.5']}) / 2))` }, - md: { marginTop: `max(0px, calc((1lh - ${space['4']}) / 2))` }, + sm: { marginBlockStart: `max(0px, calc((1lh - ${space['3.5']}) / 2))` }, + md: { marginBlockStart: `max(0px, calc((1lh - ${space['4']}) / 2))` }, }); export const indicatorSizes = stylex.create({ diff --git a/packages/mosaic/src/components/radio/radio.styles.ts b/packages/mosaic/src/components/radio/radio.styles.ts index 5d04a7ea2ce..ad70cb08133 100644 --- a/packages/mosaic/src/components/radio/radio.styles.ts +++ b/packages/mosaic/src/components/radio/radio.styles.ts @@ -77,8 +77,8 @@ export const sizes = stylex.create({ }); export const firstLine = stylex.create({ - sm: { marginTop: `max(0px, calc((1lh - ${space['3.5']}) / 2))` }, - md: { marginTop: `max(0px, calc((1lh - ${space['4']}) / 2))` }, + sm: { marginBlockStart: `max(0px, calc((1lh - ${space['3.5']}) / 2))` }, + md: { marginBlockStart: `max(0px, calc((1lh - ${space['4']}) / 2))` }, }); export const indicatorSizes = stylex.create({ diff --git a/packages/mosaic/src/components/switch/switch.styles.ts b/packages/mosaic/src/components/switch/switch.styles.ts index ae19cb2bafe..ef1b0ec2728 100644 --- a/packages/mosaic/src/components/switch/switch.styles.ts +++ b/packages/mosaic/src/components/switch/switch.styles.ts @@ -108,6 +108,6 @@ export const sizes = stylex.create({ }); export const firstLine = stylex.create({ - sm: { marginTop: `max(0px, calc((1lh - ${space['3.5']}) / 2))` }, - md: { marginTop: `max(0px, calc((1lh - ${space['4.5']}) / 2))` }, + sm: { marginBlockStart: `max(0px, calc((1lh - ${space['3.5']}) / 2))` }, + md: { marginBlockStart: `max(0px, calc((1lh - ${space['4.5']}) / 2))` }, }); diff --git a/packages/mosaic/src/components/tabs/tabs.styles.ts b/packages/mosaic/src/components/tabs/tabs.styles.ts index 02cf6ffb29a..574f139b2c9 100644 --- a/packages/mosaic/src/components/tabs/tabs.styles.ts +++ b/packages/mosaic/src/components/tabs/tabs.styles.ts @@ -60,8 +60,8 @@ export const styles = stylex.create({ fontSize: typeScaleVars['--cl-text-sm-size'], lineHeight: typeScaleVars['--cl-text-sm-leading'], opacity: { default: 1, ':is([data-disabled])': 0.5 }, - paddingBottom: space['3'], - paddingTop: space['2.5'], + paddingBlockEnd: space['3'], + paddingBlockStart: space['2.5'], }, anchor: { // eslint-disable-next-line @stylexjs/valid-styles -- `anchor-name`: anchor positioning postdates StyleX's property allowlist; it compiles and emits correctly. diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.styles.ts b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.styles.ts index 8d2a9eb203e..41f6db55c9c 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.styles.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.styles.ts @@ -4,7 +4,7 @@ import { colorVars, space } from '../../../tokens.stylex'; export const styles = stylex.create({ addError: { - marginTop: `calc(-1 * ${space['0.5']})`, + marginBlockStart: `calc(-1 * ${space['0.5']})`, }, icon: { color: colorVars['--cl-color-foreground-secondary'], diff --git a/packages/mosaic/src/styles/feedback.styles.ts b/packages/mosaic/src/styles/feedback.styles.ts index eeac8068e2e..a11ecec487e 100644 --- a/packages/mosaic/src/styles/feedback.styles.ts +++ b/packages/mosaic/src/styles/feedback.styles.ts @@ -9,6 +9,7 @@ export const feedbackStyles = stylex.create({ overflow: 'clip', alignContent: 'end', display: 'grid', + marginBlockStart: `calc(-1 * ${GAP})`, maskImage: `linear-gradient(to bottom, transparent, black ${GAP})`, position: 'relative', transitionDuration: durationVars['--cl-duration-slow'], @@ -24,13 +25,13 @@ export const feedbackStyles = stylex.create({ default: `calc(var(--_cl-feedback-height) + ${GAP})`, ':where(:not([data-open]), [data-starting-style])': 0, }, - marginTop: `calc(-1 * ${GAP})`, }, message: { inset: { default: null, ':where([data-ending-style])': 'auto 0 0' }, gap: space['1'], alignItems: 'flex-start', display: 'flex', + marginBlockStart: GAP, opacity: { default: 1, ':where([data-starting-style], [data-ending-style])': 0 }, // Leaves the flow while it exits so the message arriving behind it takes the row. position: { default: null, ':where([data-ending-style])': 'absolute' }, @@ -46,7 +47,6 @@ export const feedbackStyles = stylex.create({ default: easingVars['--cl-ease-enter'], ':where([data-ending-style])': easingVars['--cl-ease-exit'], }, - marginTop: GAP, }, icon: { flexShrink: 0,