Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions config/eslint/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,15 @@ const config = defineConfig([
},
},

// The styles layer composes tokens out of `variables`, so it reads them by name. Raw numeric literals stay banned.
{
files: ['src/styles/**'],
ignores: ['src/styles/typography.ts', 'src/styles/variables.ts'],
rules: {
'rulesdir/no-raw-typography': ['error', {allowVariablesReferences: true}],
},
},

// Restrict `computeReportName` imports everywhere except the one file that
// legitimately consumes it. This block overrides the main `no-restricted-imports`
// for ts/tsx files, so we re-apply the main `restrictedImportPaths`/`restrictedImportPatterns`
Expand Down
8 changes: 6 additions & 2 deletions config/eslint/eslint.seatbelt.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,7 @@
"../../src/components/Tooltip/GenericTooltip.tsx" "react-hooks/set-state-in-effect" 1
"../../src/components/Tooltip/PopoverAnchorTooltip.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/components/Tooltip/PopoverAnchorTooltip.tsx" "react-hooks/refs" 7
"../../src/components/TransactionItemRow/DataCells/ChatBubbleCell.tsx" "rulesdir/no-raw-typography" 2
"../../src/components/TransactionItemRow/DataCells/MerchantCell.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/components/TransactionItemRow/EditableCell/usePopoverEditState.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/components/UpdateAppModal.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1
Expand Down Expand Up @@ -459,7 +460,7 @@
"../../src/hooks/useLazyAsset.ts" "@typescript-eslint/no-unsafe-type-assertion" 3
"../../src/hooks/useLazyAsset.ts" "react-hooks/set-state-in-effect" 1
"../../src/hooks/useListKeyboardNav.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/hooks/useMarkdownStyle.ts" "rulesdir/no-raw-typography" 2
"../../src/hooks/useMarkdownStyle.ts" "rulesdir/no-raw-typography" 3
"../../src/hooks/useNativeCamera.ts" "react-hooks/refs" 1
"../../src/hooks/useNewTransactions.ts" "react-hooks/refs" 2
"../../src/hooks/useOnyx.ts" "@typescript-eslint/no-unsafe-type-assertion" 10
Expand Down Expand Up @@ -1132,6 +1133,8 @@
"../../src/pages/signin/SignInPage.tsx" "react-hooks/set-state-in-effect" 1
"../../src/pages/signin/SignInPageLayout/BackgroundImage/index.native.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/pages/signin/SignInPageLayout/FooterRow/index.native.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/pages/signin/SignInPageLayout/SignInHeroCopy.tsx" "rulesdir/no-raw-typography" 2
"../../src/pages/signin/SignInPageLayout/SignInPageContent.tsx" "rulesdir/no-raw-typography" 2
"../../src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.tsx" "react-hooks/set-state-in-effect" 4
"../../src/pages/tasks/DynamicTaskShareDestinationSelectorModal.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/pages/wallet/WalletStatementPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
Expand Down Expand Up @@ -1301,7 +1304,7 @@
"../../src/stories/ReportActionItemImages.stories.tsx" "no-restricted-syntax" 1
"../../src/stories/TextInput.stories.tsx" "react-hooks/set-state-in-effect" 1
"../../src/styles/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/styles/index.ts" "rulesdir/no-raw-typography" 29
"../../src/styles/index.ts" "rulesdir/no-raw-typography" 28
"../../src/styles/theme/utils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/styles/utils/FontUtils/fontFamily/multiFontFamily/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/styles/utils/autoCompleteSuggestion/index.web.ts" "no-restricted-syntax" 1
Expand Down Expand Up @@ -1387,6 +1390,7 @@
"../../tests/actions/ReportTest.ts" "no-restricted-imports" 1
"../../tests/actions/TaskTest.ts" "no-restricted-imports" 1
"../../tests/actions/TransactionTest.ts" "no-restricted-imports" 1
"../../tests/ui/ComposerTest.tsx" "rulesdir/no-raw-typography" 1
"../../tests/ui/MoneyRequestReportPreview.test.tsx" "no-restricted-imports" 1
"../../tests/ui/WorkspaceMoreFeaturesPageTest.tsx" "no-restricted-imports" 1
"../../tests/ui/components/Button.tsx" "no-restricted-imports" 1
Expand Down
176 changes: 162 additions & 14 deletions eslint-plugin-local-rules/no-raw-typography.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,27 @@ const meta = {
description: 'Disallow raw numeric fontSize/lineHeight values. Type must come from the typography scale so it cannot drift from the design system.',
recommended: 'error',
},
schema: [],
schema: [
{
type: 'object',
properties: {
// For the styles layer, which composes tokens out of `variables`. Raw numeric literals stay banned there.
allowVariablesReferences: {type: 'boolean'},
},
additionalProperties: false,
},
],
messages: {
rawTypography: 'Raw `{{property}}: {{value}}` is not allowed. Use a `<Text variant="...">` or a token from src/styles/typography.ts (https://github.com/Expensify/App/issues/37503).',
rawTypographyVariable:
'`{{property}}: {{value}}` bypasses the typography scale. Use a `<Text variant="...">` or a token from src/styles/typography.ts (https://github.com/Expensify/App/issues/37503).',
},
};

const BANNED_PROPERTIES = new Set(['fontSize', 'lineHeight']);
const BANNED_VARIABLE_PREFIXES = ['fontSize', 'lineHeight'];
const TYPOGRAPHY_STYLE_HELPERS = new Set(['getFontSizeStyle', 'getLineHeightStyle']);
const VARIABLES_MODULE_NAME = 'variables';

/**
* @param {import('estree').Node} key
Expand All @@ -34,54 +48,188 @@ function getPropertyName(key) {
*/
const TS_WRAPPER_TYPES = new Set(['TSAsExpression', 'TSSatisfiesExpression', 'TSNonNullExpression', 'TSTypeAssertion']);

function isNumericLiteral(node) {
function unwrap(node) {
if (TS_WRAPPER_TYPES.has(node.type)) {
return isNumericLiteral(node.expression);
return unwrap(node.expression);
}
if (node.type === 'Literal' && typeof node.value === 'number') {
return node;
}

function isNumericLiteral(node) {
const unwrapped = unwrap(node);
if (unwrapped.type === 'Literal' && typeof unwrapped.value === 'number') {
return true;
}
return node.type === 'UnaryExpression' && (node.operator === '-' || node.operator === '+') && isNumericLiteral(node.argument);
return unwrapped.type === 'UnaryExpression' && (unwrapped.operator === '-' || unwrapped.operator === '+') && isNumericLiteral(unwrapped.argument);
}

/**
* Matches `variables.fontSize*` / `variables.lineHeight*`, the named escape hatch around the scale.
* Syntactic match on the `variables.<name>` shape, so a renamed or destructured import is not flagged.
*
* @param {import('estree').Node} node
* @returns {boolean}
*/
function isVariablesTypographyReference(node) {
const unwrapped = unwrap(node);
if (unwrapped.type !== 'MemberExpression' || unwrapped.computed) {
return false;
}
if (unwrapped.object.type !== 'Identifier' || unwrapped.object.name !== VARIABLES_MODULE_NAME) {
return false;
}
if (unwrapped.property.type !== 'Identifier') {
return false;
}
return BANNED_VARIABLE_PREFIXES.some((prefix) => unwrapped.property.name.startsWith(prefix));
}

/**
* @param {import('eslint').Scope.Scope | null} scope
* @param {string} variableName
* @returns {import('eslint').Scope.Variable | undefined}
*/
function findVariable(scope, variableName) {
for (let current = scope; current; current = current.upper) {
const variable = current.set.get(variableName);
if (variable) {
return variable;
}
}
return undefined;
}

/**
* Flags object properties (`{fontSize: 17}`) and JSX attributes (`<Text fontSize={17}>`) that set
* `fontSize`/`lineHeight` to a numeric literal. References to tokens or computed values are allowed.
* The expression a single-definition `const` alias was assigned, so `const size = variables.fontSizeXXSmall`
* plus `getFontSizeStyle(size)` is still caught. Only `variables.*` is traced, never bare numbers.
*
* @param {import('eslint').Scope.Variable} variable
* @returns {import('estree').Node | undefined}
*/
function getConstInitializer(variable) {
if (variable.defs.length !== 1) {
return undefined;
}
const definition = variable.defs.at(0);
if (definition.type !== 'Variable' || definition.parent.kind !== 'const' || definition.node.id.type !== 'Identifier') {
return undefined;
}
return definition.node.init ?? undefined;
}

/**
* Flags object properties, JSX attributes, and `getFontSizeStyle()`/`getLineHeightStyle()` arguments that
* set type outside the typography scale. With `allowVariablesReferences`, only numeric literals are banned.
*
* @param {import('eslint').Rule.RuleContext} context
* @returns {import('eslint').Rule.RuleListener}
*/
function create(context) {
function report(valueNode, propertyName) {
const allowVariablesReferences = context.options.at(0)?.allowVariablesReferences ?? false;

function report(valueNode, propertyName, bannedValue) {
context.report({
node: valueNode,
messageId: 'rawTypography',
messageId: bannedValue.messageId,
data: {
property: propertyName,
value: context.sourceCode.getText(valueNode),
value: context.sourceCode.getText(bannedValue.node),
},
});
}

/**
* Walks past ternaries and `const` aliases so the banned value is found wherever it was written,
* not only when it sits directly in the banned position.
*
* @param {import('estree').Node} valueNode
* @param {Set<import('eslint').Scope.Variable>} visitedVariables guards against cyclic aliases
* @param {boolean} isBehindAlias set once the walk has stepped through a `const`, after which bare numbers are not flagged
* @returns {{messageId: string, node: import('estree').Node} | undefined}
*/
function findBannedValue(valueNode, visitedVariables, isBehindAlias) {
const unwrapped = unwrap(valueNode);
if (!isBehindAlias && isNumericLiteral(unwrapped)) {
return {messageId: 'rawTypography', node: unwrapped};
}
if (!allowVariablesReferences && isVariablesTypographyReference(unwrapped)) {
return {messageId: 'rawTypographyVariable', node: unwrapped};
}
if (unwrapped.type === 'ConditionalExpression') {
return findBannedValue(unwrapped.consequent, visitedVariables, isBehindAlias) ?? findBannedValue(unwrapped.alternate, visitedVariables, isBehindAlias);
}
if (unwrapped.type !== 'Identifier') {
return undefined;
}
const variable = findVariable(context.sourceCode.getScope(unwrapped), unwrapped.name);
if (!variable || visitedVariables.has(variable)) {
return undefined;
}
visitedVariables.add(variable);
const initializer = getConstInitializer(variable);
return initializer ? findBannedValue(initializer, visitedVariables, true) : undefined;
}

function getBannedValue(valueNode) {
return findBannedValue(valueNode, new Set(), false);
}

/**
* `getFontSizeStyle(x)` and `StyleUtils.getLineHeightStyle(x)` both build a `{fontSize}` /
* `{lineHeight}` style, so their argument is the same escape hatch as the property itself.
*/
function getTypographyHelperName(callee) {
if (callee.type === 'Identifier') {
return callee.name;
}
if (callee.type === 'MemberExpression' && !callee.computed && callee.property.type === 'Identifier') {
return callee.property.name;
}
return undefined;
}

return {
Property(node) {
if (node.computed) {
return;
}
const propertyName = getPropertyName(node.key);
if (propertyName === undefined || !BANNED_PROPERTIES.has(propertyName) || !isNumericLiteral(node.value)) {
if (propertyName === undefined || !BANNED_PROPERTIES.has(propertyName)) {
return;
}
report(node.value, propertyName);
const bannedValue = getBannedValue(node.value);
if (!bannedValue) {
return;
}
report(node.value, propertyName, bannedValue);
},
JSXAttribute(node) {
if (node.name.type !== 'JSXIdentifier' || !BANNED_PROPERTIES.has(node.name.name)) {
return;
}
if (node.value?.type !== 'JSXExpressionContainer' || !isNumericLiteral(node.value.expression)) {
if (node.value?.type !== 'JSXExpressionContainer') {
return;
}
const bannedValue = getBannedValue(node.value.expression);
if (!bannedValue) {
return;
}
report(node.value.expression, node.name.name, bannedValue);
},
CallExpression(node) {
const helperName = getTypographyHelperName(node.callee);
if (helperName === undefined || !TYPOGRAPHY_STYLE_HELPERS.has(helperName)) {
return;
}
const argument = node.arguments.at(0);
if (!argument) {
return;
}
const bannedValue = getBannedValue(argument);
if (!bannedValue) {
return;
}
report(node.value.expression, node.name.name);
report(argument, helperName === 'getFontSizeStyle' ? 'fontSize' : 'lineHeight', bannedValue);
},
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Text from '@components/Text';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';

import variables from '@styles/variables';
import {fontScale, lineHeightScale} from '@styles/typography';

import CONST from '@src/CONST';

Expand All @@ -19,7 +19,7 @@ function BulletItemRenderer({tnode}: {tnode: TNode}) {

return (
<View style={[styles.flexRow, styles.w100]}>
<Text style={{color: theme.text, fontSize: variables.fontSizeNormal, lineHeight: variables.fontSizeNormalHeight, paddingHorizontal: 8}}>{CONST.DOT_SEPARATOR}</Text>
<Text style={{color: theme.text, fontSize: fontScale.text, lineHeight: lineHeightScale.text, paddingHorizontal: 8}}>{CONST.DOT_SEPARATOR}</Text>
<View style={styles.flex1}>
<TNodeChildrenRenderer tnode={tnode} />
</View>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import useLocalize from '@hooks/useLocalize';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';

import variables from '@styles/variables';
import {fontScale} from '@styles/typography';

import type {CustomRendererProps, TBlock} from 'react-native-render-html';

Expand All @@ -16,11 +16,11 @@ function EditedRenderer({tnode, TDefaultRenderer, style, ...defaultRendererProps
const {translate} = useLocalize();
const isPendingDelete = !!(tnode.attributes.deleted !== undefined);
return (
<Text fontSize={variables.fontSizeSmall}>
<Text fontSize={variables.fontSizeSmall}> </Text>
<Text fontSize={fontScale.micro}>
<Text fontSize={fontScale.micro}> </Text>
<Text
{...defaultRendererProps}
fontSize={variables.fontSizeSmall}
fontSize={fontScale.micro}
color={theme.textSupporting}
style={[styles.editedLabelStyles, isPendingDelete && styles.offlineFeedbackDeleted]}
>
Expand Down
5 changes: 3 additions & 2 deletions src/components/MenuItem/MenuItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {getAccountIDFromAvatarID} from '@libs/UserAvatarUtils';
import TextWithEmojiFragment from '@pages/inbox/report/comment/TextWithEmojiFragment';
import {showContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu';

import {fontScale, lineHeightScale} from '@styles/typography';
import variables from '@styles/variables';

import {callFunctionIfActionIsAllowed} from '@userActions/Session';
Expand Down Expand Up @@ -691,8 +692,8 @@ function MenuItem({
const descriptionTextStyles = StyleUtils.combineStyles<TextStyle>([
styles.textLabelSupporting,
styles.flex1,
title ? {} : StyleUtils.getFontSizeStyle(variables.fontSizeNormal),
title ? styles.textLineHeightNormal : StyleUtils.getLineHeightStyle(variables.fontSizeNormalHeight),
title ? {} : StyleUtils.getFontSizeStyle(fontScale.text),
title ? styles.textLineHeightNormal : StyleUtils.getLineHeightStyle(lineHeightScale.text),
!descriptionAddon && hasIcon ? styles.ml3 : {},
descriptionAddon ? styles.ml2 : {},
(descriptionTextStyle as TextStyle) || styles.breakWord,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import Text from '@components/Text';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';

import variables from '@styles/variables';
import {fontScale, lineHeightScale} from '@styles/typography';

import React from 'react';

Expand All @@ -19,7 +19,7 @@ function MenuItemDescriptionPlaceholder({children, numberOfLines = 2}: MenuItemD

return (
<Text
style={[styles.textLabelSupporting, StyleUtils.getFontSizeStyle(variables.fontSizeNormal), StyleUtils.getLineHeightStyle(variables.fontSizeNormalHeight), styles.breakWord]}
style={[styles.textLabelSupporting, StyleUtils.getFontSizeStyle(fontScale.text), StyleUtils.getLineHeightStyle(lineHeightScale.text), styles.breakWord]}
numberOfLines={numberOfLines}
>
{children}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import useThemeStyles from '@hooks/useThemeStyles';
import {getDecodedFullCategoryName} from '@libs/CategoryUtils';
import {getCommaSeparatedTagNameWithSanitizedColons} from '@libs/PolicyUtils';

import {fontScale, lineHeightScale} from '@styles/typography';
import variables from '@styles/variables';

import CONST from '@src/CONST';
Expand Down Expand Up @@ -80,7 +81,7 @@ function MoneyRequestReportGroupHeader({
const formattedAmount = convertToDisplayString(group.subTotalAmount, currency);
const shouldShowCheckbox = isSelectionModeEnabled || !shouldUseNarrowLayout;

const textStyle = shouldUseNarrowLayout ? {fontSize: variables.fontSizeLabel, lineHeight: variables.lineHeightNormal} : [styles.labelStrong];
const textStyle = shouldUseNarrowLayout ? {fontSize: fontScale.label, lineHeight: lineHeightScale.label} : [styles.labelStrong];

const handleToggleSelection = () => {
onToggleSelection?.(groupKey);
Expand Down
Loading
Loading