Skip to content

feat: Migrate SCSS to plain CSS with PostCSS, replace Stylelint with Biome#9584

Open
luarmr wants to merge 8 commits intodevelopfrom
fb-echo-436/scss-loader-swap--ls-label-studioweb
Open

feat: Migrate SCSS to plain CSS with PostCSS, replace Stylelint with Biome#9584
luarmr wants to merge 8 commits intodevelopfrom
fb-echo-436/scss-loader-swap--ls-label-studioweb

Conversation

@luarmr
Copy link
Contributor

@luarmr luarmr commented Mar 11, 2026

Summary

Complete migration from SCSS (Sass) to plain CSS with PostCSS, eliminating the Sass compiler dependency from the build pipeline and replacing Stylelint with Biome for CSS linting.

Impact at a Glance

Metric Value
yarn.lock lines removed 691 (19,785 → 19,094)
Direct dependencies removed 5 (sass, sass-loader, stylelint, stylelint-config-standard-scss, stylelint-config-tailwindcss)
Transitive deps stubbed 8 via resolutionsempty-npm-package
CI workflow removed 1 (stylelint.yml — 36 lines)
Obsolete tools deleted 2 (validate-scss-transform.js, fix-bare-nesting.js — 616 lines)
Config files deleted 3 (.stylelintrc.json, .stylelintignore, .github/workflows/stylelint.yml)
CSS files renamed 242 (74 .module.scss.module.css, 168 .scss.prefix.css)
Import paths updated 254 JS/TS files
Net diff 524 files changed, +523 / −1,911 lines
Build warnings eliminated 6 → 0 (all 6 were sass-loader Deprecation Warnings)

Build Warnings: 6 → 0

On develop, every production build emits 6 webpack warnings — all from sass-loader:

WARNING in ../../libs/editor/src/components/App/Grid.module.scss
  Module Warning (from sass-loader): Deprecation Warning — Sass @import rules are deprecated
  and will be removed in Dart Sass 3.0.0.

WARNING in ./src/app/App.scss
  Module Warning (from sass-loader): Deprecation Warning — Sass @import rules are deprecated
  and will be removed in Dart Sass 3.0.0.
  (×4 — one per @import chain: App.scss, tokens.scss, variables.scss, global.scss)

On this branch the build compiles with 0 warningssass-loader is gone, so the deprecation warnings are gone with it.


Build Pipeline

  • Swap sass-loader for postcss-nested: All .scss files are now plain CSS processed by PostCSS with the postcss-nested plugin for Sass-compatible & nesting
  • Rewrite webpack config: CSS rules are now detected and configured directly (no dependency on Nx's SCSS rule generation). Defensive filtering strips sass-loader, stylus-loader, and less-loader from any Nx-generated rules
  • Fix css-loader detection bug: Changed includes("css-loader") to includes("/css-loader/") because postcss-loader's path contains the substring "css-loader", which caused modules options to be incorrectly applied to PostCSS loader

File Renames

  • 74 .module.scss.module.css: CSS Modules files (hashed localIdentName)
  • 168 .scss.prefix.css: BEM-prefixed files (predictable lsf-[local] class names)
  • 254 JS/TS files updated with new import paths

Dependency Removal

  • Removed direct deps: sass, sass-loader, stylelint, stylelint-config-standard-scss, stylelint-config-tailwindcss
  • Added: postcss-nested (lightweight — single PostCSS plugin)
  • Stubbed transitive deps via resolutions: sass, sass-embedded, stylus, less, less-loader, stylelint, stylelint-config-tailwindcss, stylelint-config-standardnpm:[email protected]
  • yarn.lock: 691 lines removed (19,785 → 19,094)

Linting: Stylelint → Biome

  • Deleted: .stylelintrc.json, .stylelintignore, .github/workflows/stylelint.yml
  • Removed: Stylelint from CI pipelines (cicd_pipeline.yml, cicd_pipeline_develop.yml), pre-commit hooks
  • Enabled: Biome CSS linter with cssModules parser support
  • Disabled noisy rules: noUnknownTypeSelector, noUnknownPseudoClass, noDescendingSpecificity (off)
  • Downgraded existing issues to warnings: noUnknownMediaFeatureName, noUnknownProperty, noDuplicateProperties, noShorthandPropertyOverrides
  • Fixed 3 CSS parse errors caught by Biome: missing semicolon in PersonalAccessToken.module.css, stray semicolon in Taxonomy.module.css, comma inside :global() in Taxonomy.prefix.css

Tooling & Config Updates

  • nx.json: style: scssstyle: css for all generators
  • jest.config: Module name mappers updated from scss|sass|less to css
  • .gitignore: Removed .sass-cache and .scss-snapshots/
  • design-tokens-converter: Output path changed from tokens.scss to tokens.prefix.css
  • Removed obsolete tools: validate-scss-transform.js (296 lines), fix-bare-nesting.js (320 lines)
  • extract-antd-no-reset.mjs: Removed stylelint-disable from generated header
  • Storybook webpack config: Same loader rewriting and css-loader detection fix applied
  • postcss.config.js: Added postcss-nested plugin

Documentation

  • .cursor/rules/: design.mdc, tailwind.mdc, react.mdc updated (SCSS → CSS, .module.scss.module.css, tokens.scsstokens.prefix.css, stylelint → biome)
  • DESIGN.md: All SCSS references updated, code blocks changed from scss to css
  • web/libs/ui/README.md: SCSS → CSS in migration instructions
  • web/README.md: yarn lint-scssyarn lint-css

How CSS Output Equivalence Was Validated

A rigorous multi-step process confirmed that the compiled CSS output is functionally identical before and after the migration:

Step 1: Baseline Capture (before changes)

git stash
npx nx reset
npx nx build labelstudio --skip-nx-cache
cp dist/apps/labelstudio/static/css/*.css /tmp/css-before/

Step 2: Post-Migration Build

git stash pop
npx nx reset
npx nx build labelstudio --skip-nx-cache
cp dist/apps/labelstudio/static/css/*.css /tmp/css-after/

Step 3: Normalized Comparison

CSS output differs in chunk hashes (webpack content-hashing changes when file extensions change), so a normalization step was applied:

# Normalize both sides: strip content hashes, source maps, color format differences
for dir in /tmp/css-before /tmp/css-after; do
  for f in "$dir"/*.css; do
    sed -E \
      -e 's/--[a-f0-9]{5,8}/--HASH/g' \
      -e 's/\.[a-f0-9]{8}\./\.HASH\./g' \
      -e 's|/\*# sourceMappingURL=.*\*/||g' \
      -e 's/rgba\(([0-9]+),\s*([0-9]+),\s*([0-9]+),\s*1\)/rgb(\1,\2,\3)/g' \
      "$f" > "${f}.normalized"
  done
done

Step 4: Diff Analysis

diff /tmp/css-before/*.normalized /tmp/css-after/*.normalized

Result: Zero functional differences. The only cosmetic differences found were:

  • Color format: Sass outputs rgba(r,g,b,1) vs PostCSS outputs rgb(r,g,b) — functionally identical per CSS spec
  • :not() selector: Sass expands :not(.a.b) to :not(.a):not(.b) vs PostCSS preserves :not(.a.b) — functionally identical per CSS Selectors Level 4
  • Non-breaking space encoding: Sass outputs \a0 escape vs PostCSS outputs literal U+00A0 byte — confirmed identical via xxd hex dump

Step 5: Hash Verification

After normalization, MD5 hashes of all CSS bundles were compared and matched.


Everyday Developer Impact

What changes for you

Before After
Create .module.scss for component styles Create .module.css — same syntax, nesting still works via postcss-nested
Create .scss for BEM-prefixed styles Create .prefix.css — same lsf-[local] prefixing behavior
Run yarn lint-scss Run yarn lint-css (uses Biome now)
Configure Stylelint rules in .stylelintrc.json Configure CSS rules in biome.json under css section
Install Stylelint VS Code extension Biome extension handles CSS linting too (already installed)
Use @import with aliases Use relative paths in @import (postcss-import doesn't resolve webpack aliases)

What stays the same

  • @apply Tailwind directives work identically
  • CSS Modules (import styles from './foo.module.css') work identically
  • BEM class prefixing (import './foo.prefix.css' with cn("block")) works identically
  • :global() pseudo-class works identically (it's part of the CSS Modules spec, not Sass)
  • Nesting with & works identically (via postcss-nested)
  • All design tokens, variables, and custom properties are unchanged

Test Plan

  • npx nx build labelstudio succeeds with zero warnings (except expected svg export warning)
  • CSS output functionally equivalent after hash normalization (verified via 5-step process above)
  • npx biome check . passes with zero errors
  • No .scss or .sass files remain in the repository
  • All existing unit tests pass
  • Storybook builds successfully
  • :global() pseudo-class continues working (CSS Modules spec, not Sass)
  • Visual regression check in staging
  • Cypress E2E suite passes

@luarmr luarmr requested review from a team, hlomzik and nick-skriabin as code owners March 11, 2026 00:45
@netlify
Copy link

netlify bot commented Mar 11, 2026

Deploy Preview for label-studio-docs-new-theme canceled.

Name Link
🔨 Latest commit d8d91c0
🔍 Latest deploy log https://app.netlify.com/projects/label-studio-docs-new-theme/deploys/69b0c0c381004e0008cb356e

@netlify
Copy link

netlify bot commented Mar 11, 2026

Deploy Preview for label-studio-playground canceled.

Name Link
🔨 Latest commit d8d91c0
🔍 Latest deploy log https://app.netlify.com/projects/label-studio-playground/deploys/69b0c0c37f82c50008eff1d3

@netlify
Copy link

netlify bot commented Mar 11, 2026

Deploy Preview for heartex-docs canceled.

Name Link
🔨 Latest commit d8d91c0
🔍 Latest deploy log https://app.netlify.com/projects/heartex-docs/deploys/69b0c0c3c0fde30008e0c3e2

@netlify
Copy link

netlify bot commented Mar 11, 2026

Deploy Preview for label-studio-storybook canceled.

Name Link
🔨 Latest commit d8d91c0
🔍 Latest deploy log https://app.netlify.com/projects/label-studio-storybook/deploys/69b0c0c3747e8a00089a5204

luarmr added 8 commits March 10, 2026 18:09
Replace sass-loader with postcss-nested in the webpack build pipeline.
This removes the Sass compiler dependency and processes all .scss files
through PostCSS instead.

Changes:
- Remove sass-loader from SCSS webpack rules
- Add postcss-nested plugin for Sass-compatible & nesting
- Add explicit .scss extensions to all @import statements
  (required by postcss-import)
- Replace @humansignal/ui alias imports with relative paths
  (postcss-import doesn't use webpack aliases)
- Convert remaining // comments to /* */ block comments
- Fix missing semicolons before nested rules

Made-with: Cursor
Rename all CSS module files from .module.scss to .module.css and update
all import references. These files no longer contain any SCSS syntax
and are processed purely by PostCSS.

72 files renamed, 83 import paths updated.

Made-with: Cursor
Rename all non-module .scss files to .prefix.css and update all import
references. Update webpack config to match .prefix.css files with the
existing SCSS processing rules (prefix class names), and exclude them
from the regular CSS rule to prevent double-processing. Also apply
custom CSS module localIdentName to the CSS rule for .module.css files.

171 files renamed, 214 import paths updated.

Made-with: Cursor
- Switch stylelint from SCSS configs to standard CSS configs
- Update lint script glob from *.scss to *.css
- Update CI workflow stylelint glob
- Update pre-commit hook for CSS files
- Update TypeScript CSS modules plugin matcher
- Update Storybook webpack config for .prefix.css handling

Made-with: Cursor
The Nx-generated rule combines CSS and SCSS into one rule with oneOf.
Keep .css in the outer test so .module.css and .css files are processed.
Add exclude on the non-module .css oneOf rules to prevent .prefix.css
from being matched before the SCSS/prefix oneOf.

Production build CSS output verified byte-for-byte identical to baseline.

Made-with: Cursor
- Remove sass, sass-loader, stylelint-config-standard-scss as direct deps
- Add resolutions to stub out sass, sass-embedded, stylus, less, less-loader
- Rewrite webpack config to find CSS rules directly (no SCSS detection)
- Fix css-loader matching bug (postcss-loader contains "css-loader" substring)
- Change nx.json generators from style: scss to style: css
- Update jest configs, gitignore, pre-commit hooks, README, storybook config
- Remove obsolete SCSS tools (validate-scss-transform, fix-bare-nesting)
- Update design-tokens-converter output path to .prefix.css

Made-with: Cursor
- Remove stylelint and stylelint-config-tailwindcss packages
- Delete .stylelintrc.json and .github/workflows/stylelint.yml
- Remove stylelint from CI pipelines (cicd_pipeline, cicd_pipeline_develop)
- Remove stylelint from pre-commit hooks
- Enable Biome CSS linter with cssModules parser support
- Disable noisy CSS rules (noUnknownTypeSelector, noDescendingSpecificity)
- Downgrade existing-code CSS issues to warnings for incremental fixing
- Fix minor CSS parse errors (missing semicolon, stray semicolon, comma in :global)
- Update lint-css script to use biome instead of stylelint

Made-with: Cursor
@luarmr luarmr force-pushed the fb-echo-436/scss-loader-swap--ls-label-studioweb branch from 70e5852 to d8d91c0 Compare March 11, 2026 01:09
@luarmr luarmr changed the title Fb: echo-436: scss loader swap feat: Migrate SCSS to plain CSS with PostCSS, replace Stylelint with Biome Mar 11, 2026
@codecov
Copy link

codecov bot commented Mar 11, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant