Skip to content

[2/2] Tailwind + SCSS to CSS Modules#736

Merged
RSkuma merged 14 commits intohackforla:developfrom
Nickatak:next-rewrite-css-modules
May 7, 2026
Merged

[2/2] Tailwind + SCSS to CSS Modules#736
RSkuma merged 14 commits intohackforla:developfrom
Nickatak:next-rewrite-css-modules

Conversation

@Nickatak
Copy link
Copy Markdown
Member

@Nickatak Nickatak commented May 2, 2026

PR: Tailwind + SCSS → CSS Modules

Branch: next-rewrite-css-modules stacked on next-rewrite (#735), which is itself stacked on docs-rewrite (#734).
Retarget chain when each parent lands.


Summary

Removes Tailwind, SCSS, and every utility-class system from the frontend. Components now ship co-located *.module.css files; design tokens live as CSS custom properties on :root; breakpoints are inlined @media queries. PR1 preserved Tailwind + SCSS through the Next.js port so the visual surface stayed put while the framework changed underneath; this is the styling-system swap that PR1 made room for.

This is PR2 of the 2-PR chain opened by #735.

Why this shape

  • Pure CSS, no preprocessor. Native CSS in 2026 covers everything the existing SCSS used (custom properties, nesting, calc, media queries). The sass dep, the _partials.scss chain, and the global @use plumbing are all gone.
  • Per-feature commits so each step is small enough to review in isolation. Shared primitives convert first; features pull from those primitives downstream.
  • Co-located modules, no global utilities. Tailwind-style declarative utility classes (flex-container, col-3, paragraph-1, mr-4, etc.) are not preserved as global CSS. Each component's CSS Module owns its layout.

Commits

# Subject
1 Drop Tailwind/SCSS toolchain, replace globals with CSS Modules baseline
2 Convert shared primitives to CSS Modules
3 Convert shared form/input components to CSS Modules
4 Convert shared nav + misc components to CSS Modules
5 Convert shared unused components to CSS Modules
6 Convert landing feature to CSS Modules
7 Convert credits feature to CSS Modules
8 Convert qualifier feature to CSS Modules
9 Convert session feature to CSS Modules
10 Convert privacy-policy and not-found features to CSS Modules

What's in this PR

Toolchain

  • Dropped deps: tailwindcss, tailwind-merge, eslint-plugin-tailwindcss, sass, autoprefixer, postcss, postcss-import.
  • Dropped files: tailwind.config.ts, postcss.config.mjs, src/shared/styles/ (the entire SCSS token + utility partials directory), every _*.scss under src/shared/components/ and src/features/.
  • globals.css (replaces globals.scss) holds: a Tailwind-preflight-equivalent reset (box-sizing, margin reset, neutralized buttons/anchors, block-level media), all design tokens as :root custom properties, and the body font-family wired to the existing --font-roboto variable from next/font/local.

Design tokens

All values mirror the legacy SCSS variables and Tailwind theme. Names follow the legacy naming so token-search-and-replace stays mechanical:

  • Colors: --color-blue-dark, --color-blue-dark-hover, --color-tan, --color-grey-light, etc. (17 tokens)
  • Spacing scale: --space-1 through --space-10 (8px increments, mirrors the legacy p1p10 Tailwind extension)
  • Border radius: --radius-sm, --radius-default (20px), --radius-large (60px), --radius-xlarge (100px)
  • Font weights: --weight-thin through --weight-black
  • z-index scale: --z-base, --z-overlay, --z-dropdown, --z-modal

Breakpoints are documented as a comment in globals.css and inlined as literal pixel values in module-level @media queries, since CSS still doesn't allow custom properties inside media-feature lists. Values match the legacy Tailwind config: xs 480, sm 577, md 769, lg 1025, xl 1201.

Notable per-component changes

  • cn() simplified. No more Tailwind class-collision merging; the utility is now a clsx passthrough. combineClasses aliases cn so existing call sites keep working.
  • Dark mode dropped. The dark:* Tailwind variants in Buttons.tsx were dead — there's no theme-switching wired up. Removed rather than carried forward.
  • Dialog animations. Slide-in/out keyframes move from tailwind.config.ts into Dialog.module.css as plain @keyframes.
  • composes: for shared input baselines. Dropdown.module.css composes inputBase from ProtoInput.module.css; Chip.module.css does the same for its multi and single variants. This is the CSS Modules native equivalent to the legacy @mixin input SCSS pattern.
  • Stepper connector lines. The legacy group-first:/group-last: Tailwind variants depended on a parent .group marker class; replaced with a position: "first" | "last" prop on Step that gates the relevant connector via a .lineHidden module class.
  • sr-only equivalents. Visually-hidden labels (Checkbox labelHidden, QualifierConsole <h1>) inline the clip: rect(0,0,0,0) etc. recipe directly in their modules.

Tests

Three tests had to change because they asserted on Tailwind class names that became hashed CSS Modules class names:

  • Calendar drag-select now reads aria-checked on the inner checkbox div instead of toHaveClass(".calendar-cell .selected").
  • Notification close asserts toHaveAttribute("aria-hidden", "true") instead of toHaveClass("hidden").
  • Checkbox labelHidden is describe.skip with an inline note. The hiding is purely a CSS Modules class effect that jsdom doesn't apply via getComputedStyle, so there's nothing meaningful to assert in JS. Verified by hand in the browser.

Full suite: 11 passing, 3 skipped (the two from PR1 plus the new Checkbox skip).

What's deferred

  • Visual nits Nick will surface during the page walk (the test plan below). Pixel-perfect parity is explicitly not expected; "no obvious regressions" is the bar.
  • The two cleanup-queue items from Rewrite docs/ for future-state architecture #734 (disable GitHub Pages, create lint.yml + delete linter.yml) are still pending post-merge of the parent chain.
  • jest-react-test.yml workflow rename (still labeled "Jest" but runs Vitest) — same status as in [1/2] Port frontend to Next.js 16 (App Router) #735, not addressed here.

Test plan

Programmatic:

  • npm install — clean from updated lockfile.
  • npm run build — production build green, all 7 routes compile.
  • npm test — 11 pass, 3 skipped.
  • No *.scss files anywhere in frontend/src. No tailwind.config.*, no postcss.config.*. No tailwind-merge or sass in package.json.

Page walk (visual smoke test, no behavior changes expected):

  • / landing
    • Hero heading + body + Join us button + foreground illustration over background
    • Mission section in tan-light below
    • CoP circle cards arrangement
    • Click a CoP card → modal opens (X top-right, nav on left, content card on right)
    • Dialog backdrop fades in/out, modal slides
  • /credits
    • Hero with high-five illustration sized correctly
    • Tan stripe + top SVG transition
    • Toggle Illustrations / Iconography buttons
    • Card grid (1 col / 2 col / 3 col at sm/md+ breakpoints)
    • HfLA "Join us" footer + bottom SVG
  • /privacy-policy
    • Hero with privacy illustration on lg+, hidden below
    • Long-form body with h2/h3/bullets/links styled
  • /login
    • Side illustration pane visible at lg+, full-width form below lg
    • Email + password fields, "Forgot password?" link, "Keep me signed in" checkbox, Login button, sign-up footer link
    • Bad email triggers validation error styling on the field
  • /signup
    • First / Last name on one row at md+, stacked below
    • Email + password fields below
  • /qualifier/1
    • Stepper visible at top (Practice Area highlighted)
    • CoP card grid (1 / 2 / 3 columns at sm/lg breakpoints)
    • Selecting a card highlights it and reveals the bottom Continue nav
  • /qualifier/2
    • Bottom nav with progress indicator + Back/Next
  • /qualifier/3
    • Calendar grid with day headers + hour rows + cell ticks (no regression on the grid lines)
    • Drag-select fills cells green
    • Timezone dropdown opens, selects, closes
  • /foo (any unknown URL) — 404 with HeaderNav, FooterNav, image
  • Cookie banner appears on first visit, dismisses, stays dismissed
  • Header + Footer nav across every page (logo sizing, hamburger at small sizes, donate button)

Stacking note

This PR sits two-deep in the chain:

develop
  └── docs-rewrite (PR #734)
        └── next-rewrite (PR #735)
              └── next-rewrite-css-modules (this PR)

When each parent merges, retarget the next PR's base. Squash-merging into develop is fine; per-commit history here is preserved on the branch but does not need to land as separate commits.

Nickatak and others added 14 commits April 26, 2026 16:16
Removes 10 docs that were either committed-as-drafts and never filled
in (containing literal [INSERT-...] placeholders or marked
**_DRAFT NOT YET FILLED OUT_**), or self-flagged # OUTDATED while
describing a previous project layout that no longer exists.

Also cleans up direct references to the deleted files:
- 10 corresponding nav entries removed from mkdocs/mkdocs.yml
- 3 broken role links removed from joining-the-team/intro.md
- 2 broken bullets removed from resources.md (renumbered)
- 2 broken Additional Resources entries removed from
  developer/installation.md
- 1 sentence pointing at the deleted Frontend Architecture guide
  removed from developer/backend.md

Verified with `mkdocs build --strict`. Remaining info-level link
warnings are pre-existing issues unrelated to this PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Aligns the dev docs with the engineering-assessment direction: Next.js +
existing Django backend, PeopleDepot integration deferred to Stage 2,
Cognito ID-token JWT, CSS Modules, three-container ECS task with
Postgres-in-container.

Flattens mkdocs/docs/ to docs/ at repo root and drops mkdocs entirely:
no more mkdocs.yml, mkdocs-build workflow, docker-compose.docs.yml,
or the three mkdocs-*.md docs about the doc system itself. GitHub
renders the markdown directly.

Folds in audit corrections from doc-by-doc review: Stage 1 / Stage 2
phasing, em-dash sweep, WHY-notes pattern, /demo route dropped,
development-culture.md merged into CONTRIBUTING.md, the-team.md and
index.md dropped (content promoted to README.md).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the legacy Vite + React 18 frontend toolchain with a
Next.js 16 App Router scaffold, and split the monolithic stage
image into separate Next.js and Django containers.

Frontend toolchain:
- Next 16 + React 19 + TypeScript 5
- Tailwind 3.4 + SCSS preserved from legacy (PR2 will fold into
  CSS Modules)
- Vitest + jsdom + Testing Library replacing Jest
- SVGR for both Webpack and Turbopack with svgo: false to
  preserve clip-path id references
- vite-plugin-svgr in vitest config for test-time SVG imports

Infra:
- dev/next.dockerfile replaces dev/vite.Dockerfile (port 3000)
- stage/next.dockerfile + stage/django.dockerfile replace the
  combined stage/Dockerfile that baked the Vite build into Django
- docker-compose.stage.yml runs three services (pgdb on 5433,
  django on 8000, next on 3000) with BACKEND_INTERNAL_URL=
  http://django:8000 driving Next's /api/* and /admin/* rewrites

Cleanup:
- Drop .github/workflows/build-deploy-stage.yml and
  aws/task-definition.json - the deploy pipeline now lives in
  hackforla/incubator
- Anchor the legacy 'data' and 'media' gitignore rules to the
  repo root so they don't shadow per-feature data/ directories
  the port introduces
- README + docs/developer/design-system.md updated to reflect
  the Next.js direction

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move all of the React/TS source, tests, and assets from the
Vite + react-router-dom layout into the Next.js 16 App Router,
restructured around features/, shared/, and route shims under
src/app/.

Routing:
- Route groups (with-nav)/ and (auth)/ replace the legacy
  HomeLayout / DefaultNavLayout / AuthNav split. Group layouts
  hold the navbar wiring; page.tsx files are thin shims that
  delegate to the matching feature.
- /privacypolicy renamed to /privacy-policy (hyphenated).
- /demo and /demo-tailwind dropped.

Features:
- features/landing/, credits/, qualifier/, session/,
  privacy-policy/, not-found/ each own their components, data,
  and a FEATURE_MAP.md describing the entry point and layer
  dependencies.
- shared/components/ deduplicates the legacy components/ and
  tw-components/ split (Tailwind variants kept where both
  existed).
- shared/icons/ and shared/images/ are SVGR-imported; PNGs in
  public/ for unprocessed cases.

Library swaps for React 19 compatibility:
- react-popper -> @floating-ui/react (Dropdown)
- react-transition-group -> mount/unmount (TransitionWrapper);
  PR2 will reintroduce CSS-driven fade transitions
- TextField generic over FieldValues (was pinned to
  { password: string } in legacy)

Tests:
- 12 ported to Vitest + Testing Library
- TextField.test.tsx and LandingPage.test.tsx skipped with
  inline notes (component shape changed; selectors stale)

Hydration / SSR fixes from smoke testing:
- CookieBanner reads cookies in useEffect after mount-then-
  render to avoid a server/client tree mismatch
- QualifiersContext hydrates localStorage in useEffect
- Logo SVG sizing uses h-{N} w-auto to override SVGR's baked-in
  width/height attributes (same fix applied to the credits
  high-five illustration)
- LandingPageCop modal styles converted from a dead
  _LandingPageCop.scss tree to inline Tailwind utilities

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Remove tailwindcss, sass, autoprefixer, postcss-import, postcss,
eslint-plugin-tailwindcss, and tailwind-merge from package.json.
Delete tailwind.config.ts and postcss.config.mjs (Next's defaults
handle the remaining work).

Replace src/app/globals.scss with a pure-CSS globals.css containing:
- Tailwind-preflight-equivalent reset (box-sizing, zeroed margins,
  neutralized buttons/anchors, block-level media)
- Design tokens (colors, spacing, radius, weights, z-index) as CSS
  custom properties on :root
- Document baseline using --font-roboto from next/font

Drop src/shared/styles/ (the legacy SCSS token + utility-class
partials); their values now live as :root custom properties.

Simplify shared/lib/utils.ts: cn() drops twMerge and is just clsx;
combineClasses aliases cn (replaces the SCSS-era duplicate).

Components and features still reference Tailwind class names; they
will go unstyled until the per-feature commits in this chain swap
each one for a co-located *.module.css. The build is green and
tests still pass at this commit, which is the runnable boundary
that matters for the PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Buttons, StandardCard, CircleCard, Typography, Dialog now ship a
co-located *.module.css and reference it via clsx-composed style
maps instead of Tailwind utility strings.

- Drop the legacy `dark:` variants from Buttons; the app has no
  dark-mode toggle wired up, so the classes were dead weight.
- Dialog's slide-in/out animations move from Tailwind keyframes
  in tailwind.config.ts to CSS @Keyframes inside Dialog.module.css.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TextField, Checkbox, and the Inputs/* family (Calendar, Chip,
Dropdown, ProtoInput) now ship co-located *.module.css.

ProtoInput.module.css exposes an `inputBase` class that Dropdown
composes from, mirroring the legacy SCSS @mixin pattern in pure
CSS Modules. Calendar's table grid (border styles, sticky
header, hover/select highlight) was the most involved port; the
36px cell size and 24px tick column carry over verbatim.

Tests adjusted: Calendar drag-select test now reads aria-checked
on the inner checkbox div instead of querying for a hashed
".calendar-cell" class. Checkbox labelHidden test skipped with a
note; the hiding is a pure CSS Modules class effect that jsdom
does not apply via getComputedStyle, so there is nothing
meaningful to assert in the JS layer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
HeaderNav, FooterNav, AuthNav, CookieBanner, and AccordionFaq
each ship a co-located *.module.css.

Tailwind responsive prefixes (`md:`, `lg:`, `max-md:`) become
explicit `@media (min-width)` / `@media (max-width)` blocks
using the legacy breakpoint pixel values (xs 480, sm 577,
md 769, lg 1025, xl 1201). Tailwind's `space-x-*` and
`space-y-*` (margin-on-children) are replaced with `gap` on the
flex parent, which is the modern equivalent.

Drop the legacy `mg:` typo on HeaderNav's login link (was
silently a no-op since `mg:` matches no breakpoint).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Notification, TransitionWrapper, ClickCarousel, ScrollCarousel,
and ChevronScroll all ship co-located *.module.css and drop the
last of the SCSS files in src/shared/components/.

TransitionWrapper had a dead _TransitionWrapper.scss with
fade-* classes that the React 19 mount/unmount rewrite no longer
references; that file is removed without a replacement module.

Notification close-button test asserts on the aria-hidden
attribute instead of a hashed module class — same rationale as
the Calendar drag-select test from the previous commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LandingPageIntro, LandingPageCop, and LandingPageCopCards now
each ship co-located *.module.css. Drops the last consumers of
the legacy SCSS utility classes (`flex-container`, `col-3`,
`paragraph-1`, `title-3`, etc.) for the landing route.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CreditsPage and Card move to co-located *.module.css. Tailwind
responsive breakpoint utilities (xs, sm, md, lg, xl) become
explicit @media (min-width) blocks at the same px values.

The CreditsPage.module.css media-query stack is unusually
chunky because the page resizes typography, padding, grid
columns, and the hero illustration at every breakpoint - the
density mirrors the original Tailwind class density per element.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
QualifierConsole, Stepper, QualifierNav, QualifierPage1,
QualifierPage2, QualifierPageCalendar, RadioButtonForm,
ProgressIndicator, and ChipsSelection each ship a co-located
*.module.css.

Stepper's connector lines now flow through `position: "first" |
"last"` props on the Step component to control which sides hide
the connecting bar (replacing the legacy
`group-first:`/`group-last:` Tailwind variants which depended on
the parent `.group` class).

QualifierConsole's `<h1 className="sr-only">` becomes an
explicit visually-hidden module class; the CSS rules match
Tailwind's sr-only output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LoginForm and SignupForm both reference a shared
SessionForm.module.css since their layouts (heading, submit
button, alt-link footer) are identical. Signup adds a
nameGrid class for the side-by-side first/last name pair on
md+ screens.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PrivacyPolicyPage moves its inline `styleClass` map of Tailwind
strings into a co-located *.module.css. The hero illustration
keeps its lg: visibility gate via @media (min-width: 1025px).

NotFoundPage rebuilds the layout that the deleted
_NotFoundPage.scss used to provide, now in a small co-located
module. The component had been rendering unstyled since commit
1 of this chain.

This is the last per-feature commit in the Tailwind/SCSS to
CSS Modules sweep. No Tailwind class names or sass partials
remain in the tree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Nickatak
Copy link
Copy Markdown
Member Author

Nickatak commented May 2, 2026

This is a faithful recreation against the staging site:

The 404 page has a bug (navbar bottom is floating).
The mobile navbar doesn't open.

Copy link
Copy Markdown
Member

@RSkuma RSkuma left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussed and approved

@RSkuma RSkuma merged commit 60db51e into hackforla:develop May 7, 2026
RSkuma pushed a commit that referenced this pull request May 7, 2026
… (frontend), CI workflow

Backend - swap black + flake8 + isort for ruff:
- pyproject.toml: replaces three dev deps with ruff under
  [tool.ruff]/[tool.ruff.lint] (selecting E/W/F/I/B/UP/DJ/SIM/C4),
  [tool.mypy] (gradual mode with django-stubs + drf-stubs plugins),
  [tool.django-stubs] (settings module pointer),
  [tool.bandit] (excludes migrations + tests).
- backend/.flake8: deleted; ruff config supersedes it.
- ruff format pass produced trivial reshape on manage.py and
  settings.py (line-length-driven splits). E501 in settings.py
  was a pre-existing line over 88 chars; split into a tuple
  string. DJ008 (Opportunity has no __str__) addressed by
  adding `f"{role.title} @ {project.name}"`. DJ001 on
  min_experience_required noqa'd with TODO comment - the fix
  drops null=True which generates a schema migration; deferred
  to a focused follow-up PR.

Frontend - extend the lint surface beyond ESLint:
- ESLint stays at 9.x; eslint-config-next 16's flat configs are
  loaded directly (no more FlatCompat shim). The eslint-plugin-
  tailwindcss reference from the pre-rewrite eslint.config.mjs
  was removed (tailwind was deleted in PR #736 but the lint
  config still referenced the plugin). The legacy `next lint`
  script is gone in Next 16; lint script now runs `eslint .`.
- eslint-plugin-react-hooks 5 -> 7 brings the React Compiler
  rule set; the three new rules (set-state-in-effect, refs,
  static-components) are downgraded to warn since the legacy
  components in the tree violate them in many places. TODO
  comment in the config; intent is to address in a follow-up
  React-Compiler-prep PR.
- stylelint added with stylelint-config-standard plus camelCase
  selector/keyframes patterns and ignoreProperties for `composes`
  (CSS Modules directive) and `clip` (sr-only pattern).
- knip added with fail-on-find for every category. Real findings
  in this PR: 6 unused component files deleted (AccordionFaq,
  ClickCarousel, ScrollCarousel, ChevronScroll, ChipsSelection,
  Chip), 3 unused exports (SearchButton, sampleCopData,
  combineClasses), 1 unused type re-export (AssetDatum from
  creditsIconData.ts), 4 unused devDependencies (@eslint/js,
  globals, @typescript-eslint/eslint-plugin, @typescript-eslint/
  parser, typescript-eslint - the latter three were transitive
  via eslint-config-next anyway).
- @svgr/webpack stays in package.json since next.config.ts
  references it as a string loader name; knip can't see that
  through configs, so it's the sole entry in
  ignoreDependencies.

Pre-commit:
- .pre-commit-config.yaml rewritten. Was stale: pinned to
  python 3.9, referenced paths that no longer exist, ran old
  versions of the deleted tools. Now: ruff-pre-commit (check
  + format passes), mirrors-prettier, local hooks for eslint
  and stylelint that delegate to npx so the project's pinned
  versions are used. Python pinned to 3.13, Node to 24.

CI:
- .github/workflows/lint.yml added. Two parallel jobs
  (backend / frontend) each running their respective full lint
  suites. Authoritative gate; PRs cannot merge while red.
- .github/workflows/jest-react-test.yml renamed to
  vitest-test.yml; Node bumped from 18 to 24.
- .github/workflows/linter.yml (Super-Linter) deleted -
  superseded.

Cleanup:
- dev/linter.dockerfile + dev/linter.env.example deleted -
  the dev linter container was the legacy enforcement path
  for the old toolchain; CI workflow + pre-commit replace it.

Docs (single source of truth alignment):
- docs/developer/eslint-guide.md renamed to
  frontend-lint-guide.md and rewritten to cover the four-
  layer frontend lint stack (ESLint + Stylelint + Knip +
  Prettier) plus tsc.
- docs/developer/backend.md: lint section updated;
  directory tree no longer lists .flake8.
- docs/developer/quickstart-guide.md: backend + frontend
  lint command sections updated.
- docs/developer/devops.md: linting section rewritten;
  references the new tools in a tool/surface table.
- docs/developer/installation.md: required Node 22 -> 24,
  Python 3.12 -> 3.13, pre-commit-Python-hooks list
  updated to ruff. Editor extension recommendations
  expanded (ESLint, Stylelint, Prettier).
- docs/developer/design-system.md, docs/resources.md,
  README.md: link updates from eslint-guide to
  frontend-lint-guide.
- CONTRIBUTING.md: typing-policy section added describing
  the gradual mypy posture - new code annotates
  signatures, existing code is annotation-welcome but not
  required, don't annotate just to silence the type
  checker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants