Skip to content

Commit 7b80238

Browse files
refactor(frontend): Replace Tailwind with StyleX (#408)
Times are a changing, and who doesn't love a good ol'refactor. The goal of this PR is to migrate away from Tailwind, and instead over to StyleX. The reason for this is that we live in a world of agents now, and constant validation is key to ensuring durable, and good code. StyleX provides this via type safe styles unlike those of Tailwind, which at the end of the day are just strings. 🤖 generated description below: --- ## Why Replace Tailwind throughout the frontend with StyleX. Shared components accept compiled styles through typed `style` props, so callers can compose overrides without class strings or raw CSS objects. ## What changed - Migrate shared components and all incident, filter, and availability screens. Remove Tailwind, CVA, classnames, tailwind-merge, and `cn`. - Generate only `tokens.stylex.ts` from Scraps colors, spacing, radii, and typography. Store direct values with dark-mode media overrides. Remove `scraps-theme.css` and the intermediate variable aliases. - Add the StyleX ESLint plugin, a rule that requires StyleX styles, and negative type and lint tests. Preserve calendar selection, keyboard focus, dynamic dimensions, and dialog scroll locking. ## Scope The change covers frontend application styling. The backend and API contracts are unchanged. No demo page remains. Global CSS retains font faces and the latest Tailwind Preflight, adapted as standalone CSS in the reset layer. All native inputs suppress focus outlines through a global reset rule. Buttons, links, selects, and textareas retain native focus. Calendar adapters use generated classes internally, and Radix manages its runtime positioning. ## Tradeoffs Scraps exports ordinary JavaScript objects that the StyleX compiler cannot import directly as tokens. A generator converts those values into a StyleX module and checks the committed output during lint. StyleX emits the CSS declarations. Preflight uses standard CSS font defaults in place of its Tailwind compiler helpers. Its MIT notice ships with the production assets. ## Blast radius Every frontend screen uses the new styling path. Review the global reset, calendar adapters, dynamic measurements, and conditional style precedence closely. ## Verification - All 193 frontend tests pass, including six style contract tests. Lint, formatting, type checking, token freshness, the production build, and applicable precommit and pre-push hooks pass. - All 118 tokens preserve their previous values in both color schemes through the installed StyleX compiler. Production CSS contains no Tailwind symbols or Scraps variable aliases. - Browser checks cover desktop and mobile layouts, light and dark rendering, calendars, keyboard focus return, dialogs, scroll unlock, and textarea resizing. Visual checks are sampled. Dark hover precedence was checked through compiled CSS, while RTL and week numbers have component test coverage. Browser checks confirm unchanged dropdown geometry at 1280px, outline removal on all five searchable dropdowns and a regular form input, and native focus on selects, textareas, and buttons. --------- Co-authored-by: Spencer Murray <spencer.murray@sentry.io>
1 parent 5f35c56 commit 7b80238

81 files changed

Lines changed: 4568 additions & 2778 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
export default {
2+
meta: {
3+
type: 'problem',
4+
schema: [],
5+
messages: {
6+
className:
7+
'Define styles with StyleX and pass a typed style prop or spread stylex.props on a host element.',
8+
inlineStyle:
9+
'Define host styles with stylex.create and apply them with stylex.props.',
10+
cssProperties: 'Component style props must accept StyleXStyles, not CSSProperties.',
11+
hostProps:
12+
'Pass StyleX styles to application components instead of spreading generated DOM props.',
13+
},
14+
},
15+
create(context) {
16+
function applicationComponent(node) {
17+
const name = node.type === 'JSXMemberExpression' ? node.object : node;
18+
if (name.type !== 'JSXIdentifier' || !/^[A-Z]/.test(name.name)) return false;
19+
let scope = context.sourceCode.getScope(node);
20+
while (scope && !scope.set.has(name.name)) scope = scope.upper;
21+
const variable = scope?.set.get(name.name);
22+
const imported = variable?.defs.find(def => def.type === 'ImportBinding');
23+
if (!imported) {
24+
return Boolean(
25+
variable?.defs.some(
26+
def =>
27+
def.type === 'FunctionName' ||
28+
(def.type === 'Variable' &&
29+
['ArrowFunctionExpression', 'FunctionExpression'].includes(
30+
def.node.init?.type
31+
))
32+
)
33+
);
34+
}
35+
const source = imported.parent.source.value;
36+
return (
37+
source.startsWith('components/') ||
38+
source.startsWith('./') ||
39+
source.startsWith('../')
40+
);
41+
}
42+
43+
function initializer(node) {
44+
if (node.type !== 'Identifier') return node;
45+
let scope = context.sourceCode.getScope(node);
46+
while (scope && !scope.set.has(node.name)) scope = scope.upper;
47+
return scope?.set.get(node.name)?.defs.find(def => def.type === 'Variable')?.node
48+
.init;
49+
}
50+
51+
function spreadValues(node, seen = new Set()) {
52+
if (!node || seen.has(node)) return [];
53+
seen.add(node);
54+
if (
55+
node.type === 'TSAsExpression' ||
56+
node.type === 'TSSatisfiesExpression' ||
57+
node.type === 'TSNonNullExpression'
58+
) {
59+
return spreadValues(node.expression, seen);
60+
}
61+
if (node.type === 'Identifier') return spreadValues(initializer(node), seen);
62+
if (node.type === 'ObjectExpression') {
63+
return node.properties.flatMap(property =>
64+
property.type === 'SpreadElement'
65+
? spreadValues(property.argument, seen)
66+
: [property]
67+
);
68+
}
69+
if (
70+
node.type === 'CallExpression' &&
71+
node.callee.type === 'MemberExpression' &&
72+
node.callee.object.name === 'stylex' &&
73+
node.callee.property.name === 'props'
74+
) {
75+
return [node];
76+
}
77+
return [];
78+
}
79+
80+
return {
81+
JSXAttribute(node) {
82+
if (node.name.name === 'className')
83+
context.report({node, messageId: 'className'});
84+
if (
85+
node.name.name === 'classNames' &&
86+
!context.filename.endsWith('/components/Calendar.tsx')
87+
) {
88+
context.report({node, messageId: 'className'});
89+
}
90+
if (
91+
node.name.name === 'style' &&
92+
(!applicationComponent(node.parent.name) ||
93+
node.value?.expression?.type === 'ObjectExpression')
94+
) {
95+
context.report({node, messageId: 'inlineStyle'});
96+
}
97+
},
98+
TSTypeReference(node) {
99+
const name =
100+
node.typeName.type === 'TSQualifiedName'
101+
? node.typeName.right.name
102+
: node.typeName.name;
103+
if (name === 'CSSProperties') context.report({node, messageId: 'cssProperties'});
104+
},
105+
AssignmentExpression(node) {
106+
if (
107+
node.left.type === 'MemberExpression' &&
108+
node.left.object.type === 'MemberExpression' &&
109+
node.left.object.property.name === 'style'
110+
) {
111+
context.report({node, messageId: 'inlineStyle'});
112+
}
113+
},
114+
JSXSpreadAttribute(node) {
115+
const host = node.parent.name;
116+
const values = spreadValues(node.argument);
117+
for (const value of values) {
118+
if (value.type !== 'Property' || value.computed) continue;
119+
const key = value.key.name ?? value.key.value;
120+
if (key === 'className' || key === 'classNames') {
121+
context.report({node: value, messageId: 'className'});
122+
}
123+
if (
124+
key === 'style' &&
125+
(!applicationComponent(host) || value.value.type === 'ObjectExpression')
126+
) {
127+
context.report({node: value, messageId: 'inlineStyle'});
128+
}
129+
}
130+
if (!values.some(value => value.type === 'CallExpression')) return;
131+
if (applicationComponent(host)) {
132+
context.report({node, messageId: 'hostProps'});
133+
}
134+
},
135+
};
136+
},
137+
};

‎frontend/eslint.config.js‎

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import js from '@eslint/js';
2+
import {rules as stylexRules} from '@stylexjs/eslint-plugin';
23
import reactHooks from 'eslint-plugin-react-hooks';
34
import reactRefresh from 'eslint-plugin-react-refresh';
4-
import eslintPluginTailwindCSS from 'eslint-plugin-tailwindcss';
55
import {globalIgnores} from 'eslint/config';
66
import globals from 'globals';
77
import tseslint from 'typescript-eslint';
88

9+
import stylexOnly from './eslint-rules/stylex-only.mjs';
10+
911
export default tseslint.config([
1012
globalIgnores(['dist']),
1113
{
@@ -15,26 +17,33 @@ export default tseslint.config([
1517
tseslint.configs.recommended,
1618
reactHooks.configs.flat.recommended,
1719
reactRefresh.configs.vite,
18-
eslintPluginTailwindCSS.configs['flat/recommended'],
1920
],
20-
settings: {
21-
tailwindcss: {
22-
// For Tailwind v4 - disable config path since it uses CSS-based config
23-
config: false,
24-
// Specify CSS files where Tailwind is defined
25-
cssFiles: ['src/styles/index.css', 'src/styles/scraps-theme.css'],
26-
},
27-
},
28-
rules: {
29-
// Disable until plugin properly supports Tailwind v4 CSS-based config
30-
'tailwindcss/no-custom-classname': 'off',
21+
plugins: {
22+
'@stylexjs': {rules: stylexRules},
23+
local: {rules: {'stylex-only': stylexOnly}},
3124
},
3225
languageOptions: {
3326
ecmaVersion: 2020,
3427
globals: globals.browser,
3528
},
3629
rules: {
37-
'tailwindcss/no-custom-classname': 'off',
30+
'@stylexjs/valid-styles': 'error',
31+
'@stylexjs/enforce-extension': 'error',
32+
'local/stylex-only': 'error',
33+
'no-restricted-imports': [
34+
'error',
35+
{
36+
paths: [
37+
'classnames',
38+
'class-variance-authority',
39+
'tailwind-merge',
40+
'tailwindcss',
41+
'@tailwindcss/vite',
42+
'utils/cn',
43+
],
44+
patterns: ['**/utils/cn', '**/cn', '@tailwindcss/*', '**/*.css', '**/*.scss'],
45+
},
46+
],
3847
},
3948
},
4049
]);

‎frontend/index.html‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
<title>Firetower</title>
1111
</head>
1212

13-
<body class="bg-background-tertiary">
13+
<body>
1414
<div id="root"></div>
1515
<script type="module" src="/src/main.tsx"></script>
1616
</body>

‎frontend/package.json‎

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,14 @@
1010
"type-check": "tsc --project tsconfig.build.json --noEmit",
1111
"prebuild": "pnpm run tokens:generate",
1212
"build": "tsc --project tsconfig.build.json --noEmit && vite build",
13-
"lint": "eslint .",
13+
"lint": "pnpm run tokens:check && eslint .",
1414
"lint:fix": "eslint . --fix",
1515
"format": "prettier --write .",
1616
"format:check": "prettier --check .",
1717
"test": "vitest run",
1818
"tokens:generate": "node scripts/generate-scraps-theme.mjs",
19-
"preview": "vite preview"
19+
"preview": "vite preview",
20+
"tokens:check": "node scripts/generate-scraps-theme.mjs --check"
2021
},
2122
"dependencies": {
2223
"@emotion/react": "11.14.0",
@@ -26,26 +27,24 @@
2627
"@sentry/react": "^10.63.0",
2728
"@sentry/scraps": "0.1.0",
2829
"@sentry/vite-plugin": "^5.4.1",
30+
"@stylexjs/stylex": "^0.19.1",
2931
"@t3-oss/env-core": "^0.13.8",
3032
"@tanstack/react-query": "^5.90.12",
3133
"@tanstack/react-router": "^1.141.1",
3234
"@tanstack/zod-adapter": "^1.141.1",
33-
"class-variance-authority": "^0.7.1",
34-
"classnames": "^2.5.1",
3535
"date-fns": "^4.1.0",
3636
"lucide-react": "^0.560.0",
3737
"react": "^19.2.2",
3838
"react-day-picker": "^9.13.0",
3939
"react-dom": "^19.2.2",
40-
"tailwind-merge": "^3.4.0",
41-
"tailwindcss": "^4.1.18",
4240
"zod": "^4.1.13"
4341
},
4442
"devDependencies": {
4543
"@eslint/js": "^9.39.1",
4644
"@happy-dom/global-registrator": "^20.0.11",
4745
"@ianvs/prettier-plugin-sort-imports": "^4.7.0",
48-
"@tailwindcss/vite": "^4.1.18",
46+
"@stylexjs/eslint-plugin": "^0.19.1",
47+
"@stylexjs/unplugin": "^0.19.1",
4948
"@tanstack/react-router-devtools": "^1.141.1",
5049
"@tanstack/router-plugin": "^1.141.1",
5150
"@testing-library/dom": "^10.4.1",
@@ -59,11 +58,11 @@
5958
"eslint": "^9.39.1",
6059
"eslint-plugin-react-hooks": "^7.0.1",
6160
"eslint-plugin-react-refresh": "^0.4.24",
62-
"eslint-plugin-tailwindcss": "^4.0.0-beta.0",
6361
"globals": "^16.5.0",
6462
"prettier": "^3.7.4",
6563
"typescript": "~5.9.3",
6664
"typescript-eslint": "^8.49.0",
65+
"unplugin": "^2.3.11",
6766
"vite": "^7.3.5",
6867
"vitest": "^3.2.6"
6968
}

0 commit comments

Comments
 (0)