Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
22 KiB
Pitfalls Research
Domain: UI polish overhaul -- Material Design 3, dark mode, accent colors added to existing Tailwind v4 + React wizard app Researched: 2026-03-31 Confidence: HIGH (based on codebase analysis + verified Tailwind v4 docs + community patterns)
Critical Pitfalls
Pitfall 1: Tailwind v4 Dark Mode Requires CSS-First Config, Not tailwind.config.js
What goes wrong:
Developers reach for tailwind.config.js with darkMode: 'class' which does not exist in Tailwind v4. The app currently has only @import "tailwindcss"; in index.css with no config file at all. Using v3 dark mode patterns produces zero effect and wastes debugging time.
Why it happens:
Most tutorials and Stack Overflow answers still reference Tailwind v3 syntax. Tailwind v4 moved to a fully CSS-first configuration model. The darkMode config key is gone.
How to avoid:
Add the @custom-variant directive in index.css for class-based toggling:
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
This enables manual toggle via a .dark class on <html>. The :where() wrapper keeps specificity at zero, preventing cascade conflicts. Verified against Tailwind v4 dark mode docs.
Warning signs:
dark:prefixed classes have no visible effect- Dark mode only responds to OS preference, not the toggle button
Phase to address:
Phase 1 (Theme Foundation) -- this must be the very first CSS change before any dark: classes are added to components.
Pitfall 2: Hardcoded Color Values Across 73 className Usages
What goes wrong:
The codebase has 73 className= usages with hardcoded Tailwind color classes (text-gray-700, border-gray-300, focus:ring-blue-300, text-red-500, bg-gray-50, etc.). Adding dark mode by appending a dark: counterpart to every single one creates unreadable className strings and guarantees missed spots -- invisible text, invisible borders, or unreadable error messages against dark backgrounds.
Why it happens:
When building light-mode-only, hardcoded color classes are natural. The cost is deferred until dark mode arrives. Developers add dark: to the visible components and miss the less-obvious ones (help text, error messages, placeholders, disabled states).
How to avoid:
Define semantic CSS custom properties (design tokens) mapped to Tailwind's @theme directive:
@theme {
--color-surface: #ffffff;
--color-on-surface: #1a1a1a;
--color-primary: #2563eb;
--color-error: #dc2626;
}
Then use bg-surface, text-on-surface throughout components. Dark mode changes the token values once (on .dark), not every component. This is the Material Design 3 approach (surface, on-surface, primary, on-primary, etc.).
Warning signs:
dark:classes appearing in JSX alongside light classes, creating 100+ character className strings- Text disappearing on dark backgrounds during manual testing
- Error messages (
text-red-600) becoming unreadable against dark backgrounds
Phase to address: Phase 1 (Theme Foundation) defines tokens. Phase 2 (Component Overhaul) replaces hardcoded colors with token references.
Pitfall 3: Dark Mode Color Contrast Failures (WCAG AA)
What goes wrong:
Text that passes 4.5:1 contrast in light mode fails in dark mode. The most common failures: gray help text on dark gray backgrounds, red error text on dark surfaces, blue links on dark blue-gray backgrounds. The current app uses text-gray-500 for help text and text-red-600 for errors -- both will fail against typical dark backgrounds.
Why it happens:
Developers assume inverting colors preserves contrast ratios. They do not. Concrete example from this codebase: text-gray-500 (#6b7280) on bg-white (#ffffff) gives 4.6:1 contrast -- barely passing AA. The same text-gray-500 on bg-gray-900 (#111827) gives only 3.5:1 -- failing AA for normal text.
How to avoid:
Define separate color values per theme within the token system. In dark mode, help text must use a lighter gray (equivalent of text-gray-400), errors must use a lighter red (equivalent of text-red-400). Semantic tokens centralize these mappings so each value is defined once. Verify every text/background pair with the browser DevTools accessibility panel or WebAIM contrast checker against WCAG AA 4.5:1 minimum for normal text, 3:1 for large text.
Warning signs:
- Help text feels "hard to read" in dark mode during visual review
- Browser DevTools accessibility audit flagging contrast ratios below 4.5:1
- Error states visually blending into background colors
Phase to address: Phase 1 (Token Definition) for color values. Phase 2 (Component Overhaul) for application. Each component restyle must include a contrast verification before marking complete.
Pitfall 4: Breaking 131 Test Selectors During Component Restyling
What goes wrong:
The test suite uses 131 occurrences of getByText, getByRole, getByTestId, getByLabelText, and queryBy selectors across 5 test files (App.test.tsx, StepIndicator.test.tsx, ReviewStep.test.tsx, RemoteConfigStep.test.tsx, BackendSelectionStep.test.tsx). Restyling components breaks tests by: changing visible text content, wrapping elements in new containers that alter DOM hierarchy, replacing native elements with styled equivalents (changing roles), or removing/renaming aria attributes.
Why it happens: UI overhauls touch the same JSX that tests query. Specific examples from this codebase:
getByText(/Backend/)in StepIndicator.test.tsx breaks if the label text changes or gets wrapped in a<span>that splits the text nodegetAllByRole('button')breaks if buttons become styled<a>tags or<div>elementsscreen.findByText('2', { selector: '[data-testid="step"]' })breaks if data-testid attributes are renamed during refactoring
How to avoid:
- Run the full 159-test suite after every single component change, not in a batch at the end.
- Restyle one component, verify tests, commit. Never batch-restyle all components then fix all tests.
- When restructuring JSX, preserve text content and element roles. A
<button>must remain a<button>. - If adding wrapper elements, ensure text nodes are not split (e.g.,
getByText(/Backend/)matches a single text node, not text across siblings).
Warning signs:
- More than 3 test failures appearing simultaneously after a restyle
- Tests failing with "Unable to find element" errors
getByRolequeries returning unexpected counts
Phase to address: Every phase -- each component change must include a "159 tests green" gate. This is the single most likely source of rework.
Pitfall 5: Flash of Unstyled Content (FOUC) on Dark Mode Load
What goes wrong:
The app loads with light mode CSS, then JavaScript runs and toggles the .dark class, causing a visible white flash. For IT professionals who often use dark OS themes, this flash is jarring and signals low quality.
Why it happens:
React runs after the initial paint. If dark mode preference is stored in localStorage and applied via useEffect or React state, the first paint is always light mode. The class toggle happens milliseconds later, but the flash is visible.
How to avoid:
Add a synchronous inline <script> in the <head> of index.html (before any CSS or React bundle loads):
<script>
if (localStorage.theme === 'dark' ||
(!('theme' in localStorage) &&
window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
</script>
This executes before first paint, preventing any flash. The React toggle component then reads and syncs with the already-applied state.
Warning signs:
- White flash visible when loading the app with dark mode previously enabled
- Users on dark OS themes seeing a brief light flash on every page load
Phase to address: Phase 1 (Theme Foundation) -- the FOUC prevention script must ship together with the dark mode toggle implementation, not as a later fix.
Pitfall 6: Theme Context Re-renders Causing Full Wizard Re-render
What goes wrong:
Adding a ThemeContext that stores { theme: 'dark', accentColor: 'blue' } causes every useTheme() consumer to re-render when any theme value changes. Since the app already has a WizardContext with useReducer, adding another context that triggers re-renders on toggle will cause all 4 wizard steps to re-render, potentially resetting form input focus or scroll position.
Why it happens:
React Context re-renders every consumer when the provider value changes (referential equality). Tutorials show <ThemeProvider value={{ theme, setTheme }}> where a new object is created on every render. Even with useMemo, toggling theme changes the value and re-renders all consumers.
How to avoid:
Do NOT store theme in React Context. Apply the theme via the .dark CSS class on <html> element and CSS custom properties. Theme toggling becomes a DOM class toggle (zero React re-renders). Store the toggle state in a small component-local state that only the toggle button uses:
function ThemeToggle() {
const [isDark, setIsDark] = useState(() =>
document.documentElement.classList.contains('dark')
);
const toggle = () => {
document.documentElement.classList.toggle('dark');
setIsDark(d => !d);
localStorage.theme = isDark ? 'light' : 'dark';
};
return <button onClick={toggle}>...</button>;
}
Only the toggle button re-renders. No context, no provider, no cascade. Accent color works the same way -- set a CSS variable on <html>, no React re-render.
Warning signs:
- Form inputs losing focus when toggling dark mode
- Visible flicker across the entire wizard when toggling
- React DevTools profiler showing all components re-rendering on theme change
Phase to address: Phase 1 (Theme Foundation) -- architecture decision: CSS class approach, not React Context for theming.
Pitfall 7: Form Accessibility Regressions During Restyling
What goes wrong:
The current FieldRenderer has proper <label htmlFor> and <input id> associations, error message display, and tooltip buttons with aria-label. During restyling, these connections break: labels get separated from inputs by decorative wrapper divs, error messages lose their visual proximity, or card wrappers introduce unexpected tab stops.
Why it happens:
Visual-focused restyling treats JSX as a canvas for layout. Developers restructure DOM for card layouts, add icon containers, or wrap form groups in Material-style "outlined" containers. The label-input-error chain relies on specific DOM relationships. The FieldRenderer currently does NOT use aria-describedby for error messages -- this is already noted as v1.1 tech debt. Restyling is the right time to fix this, but also the highest risk time to break what works.
How to avoid:
- Fix the
aria-describedbygap as part of the restyling, not separately. Addaria-describedby={error ? \${field.key}-error` : undefined}to inputs andid={`${field.key}-error`}` to error paragraphs. - After restyling each form component, verify: label click focuses the input, error messages are associated with inputs, tab order follows visual order.
- Keep the
<label htmlFor={field.key}>+<input id={field.key}>pattern intact regardless of wrapper changes.
Warning signs:
- Clicking a label no longer focuses its input
- Tab key skips inputs or gets trapped in decorative elements
- Browser form autofill stops working on restyled inputs
Phase to address: Phase 2 (Component Overhaul) -- every form component restyle must include an accessibility verification step. Resolve the v1.1 aria tech debt item here rather than deferring again.
Technical Debt Patterns
Shortcuts that seem reasonable but create long-term problems.
| Shortcut | Immediate Benefit | Long-term Cost | When Acceptable |
|---|---|---|---|
Adding dark: to every className instead of tokens |
Fast, no refactor needed | 73+ locations to maintain, every new component needs dual classes | Never -- token approach costs the same upfront and scales |
Using !important to fix specificity issues |
Immediate visual fix | Cascading specificity arms race, impossible to override later | Never |
| Storing theme only in React state (not localStorage) | Simpler code | Preference lost on refresh, FOUC on every load | Never -- localStorage + inline script is trivial |
| Skipping contrast verification "will check later" | Faster shipping | Accessibility failures discovered post-ship, painful to retroactively audit all 73 class locations | Never -- check during each component restyle |
| Building a full design system with token categories for every MD3 role | "Complete" spec adherence | Over-engineered for a 4-step wizard with ~10 components; 80% of tokens go unused | Never for this app -- pick the 15-20 tokens that matter |
| Copying MD3 token names verbatim (md-sys-color-surface-container-highest) | Matches Google spec exactly | Verbose, unfamiliar to Tailwind developers, poor DX for a small team | Never -- use simplified semantic names (surface, on-surface, primary) |
| Adding MUI or another component library for "proper" MD3 | Instant MD3 components | +200KB bundle, specificity wars with Tailwind, two styling systems to maintain | Never for this app -- 10 components do not justify a library |
Integration Gotchas
Common mistakes when connecting theme infrastructure to existing systems.
| Integration | Common Mistake | Correct Approach |
|---|---|---|
| react-hook-form + restyled inputs | Wrapping <input> in a custom component that breaks register() ref forwarding |
Use React.forwardRef on any custom input wrapper, or keep native <input> with Tailwind classes (preferred for this app) |
| Zod validation + error display | Moving error <p> tags away from their input during restyle, breaking visual association |
Keep error message immediately after its input in DOM order; add aria-describedby |
| WizardContext + theme toggle | Creating a ThemeContext provider that causes WizardProvider consumers to re-render | Theme via CSS class on <html> (zero React re-renders), NOT via React context |
| CSS hidden auth toggles (AzureAuthToggle / SftpAuthToggle) | Restyling visible state but forgetting the hidden state, breaking className="hidden" pattern |
Verify both auth toggle states render correctly in both light and dark modes |
| StepIndicator inline styles | Replacing style={{ fontWeight: 'bold' }} with Tailwind classes but altering text content structure |
Replace inline styles with Tailwind classes (font-bold, font-normal, text-muted) while keeping text content strings identical for test compatibility |
| BackendCard selection state | Changing selection indicator (e.g., border color) to use tokens but forgetting dark mode variant | Selected card must be visually distinct in both themes; test with all 7 backends |
Performance Traps
| Trap | Symptoms | Prevention | When It Breaks |
|---|---|---|---|
| Theme stored in React Context causing re-renders | All 4 wizard steps re-render on every toggle; form focus lost | CSS class on <html>, no React context for theme |
Immediate on every toggle |
| Importing full component library for 10 components | Bundle doubles (+200KB gzipped for MUI) | Build MD3 styles with Tailwind tokens; zero additional dependencies | Immediate -- slower first load |
| CSS transition on every property during theme switch | 200ms lag on every element when toggling dark mode | Transition only background-color and color on body; skip borders/shadows |
Noticeable with 50+ DOM elements |
| Over-using CSS custom properties on every element | Slow repaints when toggling theme on low-end devices | Define tokens on :root / .dark, let inheritance cascade naturally |
On low-end devices or with 100+ custom properties |
UX Pitfalls
| Pitfall | User Impact | Better Approach |
|---|---|---|
| Dark mode toggle buried in settings | IT pros who want dark mode cannot find it | Visible toggle in app header, immediately accessible |
| No system preference detection | User has OS dark mode, app loads light | Default to OS preference via prefers-color-scheme, with manual override stored in localStorage |
| Accent color picker with unlimited options | Analysis paralysis, clashing colors | 3-5 curated accent colors that all pass contrast checks in both themes |
| Theme transition animation on every element | Jarring, slow, distracting on toggle | Subtle 150ms transition on background-color and color on body only |
| Dark mode applied but OutputBlock code still light | Inconsistent feel in the most important step (Review/download) | OutputBlock must respect dark mode for generated config and script previews |
| Security warning banner lost in dark mode | Users miss the credential security warning before download | Warning must remain high-contrast and prominent (use error/warning token colors) in both modes |
"Looks Done But Isn't" Checklist
Things that appear complete but are missing critical pieces.
- Dark mode select dropdowns: Browser renders
<option>elements with OS colors -- white dropdown menus appear on dark backgrounds on some browsers. Verify on Chrome, Firefox, Edge. - Dark mode scrollbars: Light scrollbars on dark backgrounds look broken. Apply
scrollbar-colorCSS property or usedarkcolor-scheme. - Error text contrast:
text-red-600on dark backgrounds has insufficient contrast. Must use lighter red (equivalent oftext-red-400) in dark mode via tokens. - Focus rings in dark mode:
focus:ring-blue-300is nearly invisible on dark backgrounds. Must usefocus:ring-blue-500equivalent in dark mode. - Placeholder text in dark mode: Light gray placeholder text vanishes on dark input backgrounds. Verify placeholder is visible in both modes.
- Security warning banner: The credential warning in ReviewStep must remain prominent and high-contrast in dark mode (not just "inverted").
- Accent color + dark background: Verify every accent color option still passes WCAG AA 4.5:1 against the dark surface background.
- BackendCard hover and selected states: Card states must be visually distinguishable in both modes with all 7 backends.
- Disabled button contrast: Disabled buttons using lower opacity reduce contrast further on dark backgrounds. Use distinct disabled token colors instead of opacity.
- PasswordField show/hide toggle: The eye icon/button must be visible in both themes.
- Tooltip info boxes: The
bg-blue-50 border-blue-200 text-blue-700tooltip in FieldRenderer needs a dark mode equivalent that maintains readability.
Recovery Strategies
When pitfalls occur despite prevention, how to recover.
| Pitfall | Recovery Cost | Recovery Steps |
|---|---|---|
| Hardcoded colors everywhere (no tokens) | MEDIUM | Extract to CSS variables in one pass, then find-replace all 73 className usages. ~2 hours for this codebase. |
| Test suite broken by batch restyle | LOW-MEDIUM | git stash the batch change, restyle one component at a time verifying tests between each. |
| FOUC on dark mode | LOW | Add 5-line inline script to index.html <head>. 10-minute fix. |
| Specificity conflicts from component library | HIGH | Remove component library, rebuild styles with Tailwind. Prevention is far cheaper than recovery. |
| Accessibility regressions in forms | MEDIUM | Audit with browser accessibility tools, fix label/input/aria associations. Harder to find than to fix. |
| Dark mode contrast failures | LOW-MEDIUM | With centralized tokens: update token values once. Without tokens: hunt through all 73 className usages. |
| Theme context re-renders | LOW | Remove ThemeContext, move to CSS class approach. ~30 minutes if caught early. |
Pitfall-to-Phase Mapping
| Pitfall | Prevention Phase | Verification |
|---|---|---|
| Tailwind v4 dark mode config (P1) | Phase 1: Theme Foundation | dark:bg-gray-900 toggles correctly via .dark class on <html> |
| Hardcoded colors (P2) | Phase 1 (Tokens) + Phase 2 (Overhaul) | Zero hardcoded Tailwind color classes remain in component JSX |
| Dark mode contrast failures (P3) | Phase 2: Component Overhaul | Every text/background pair checked, all pass WCAG AA 4.5:1 |
| Breaking test selectors (P4) | Every phase | All 159 tests pass after each individual component restyle |
| FOUC (P5) | Phase 1: Theme Foundation | Load app with localStorage.theme = 'dark', verify no white flash |
| Theme context re-renders (P6) | Phase 1: Architecture Decision | Theme toggle causes zero React re-renders outside the toggle button itself |
| Form accessibility regressions (P7) | Phase 2: Component Overhaul | Label-input associations verified, aria-describedby added for all error messages |
| CSS specificity conflicts | Phase 1: Architecture Decision | Decision documented: no component library, Tailwind-only approach |
| Over-engineering design system | Phase 1: Token Definition | Token count stays under 20 semantic colors; no unused token categories |
Sources
- Tailwind CSS v4 Dark Mode docs -- official, verified (HIGH confidence)
- Tailwind v4 upgrade discussion #16517 -- community reports of broken dark mode after upgrade
- Tailwind specificity discussion #12714 -- class collisions with component libraries
- BOIA: Dark Mode and WCAG Contrast -- dark mode does not auto-satisfy WCAG
- Complete Dark Mode Accessibility Guide (2026) -- WCAG 2.1 AA guidance for dark mode
- MUI MD3 adoption discussion #29345 -- MD3 implementation complexity
- React Context performance optimization -- re-render prevention patterns
- Codebase analysis: 73 className usages, 131 test selectors across 5 files, 3 inline styles in StepIndicator, FieldRenderer aria-describedby gap confirmed (HIGH confidence -- direct code inspection)
Pitfalls research for: UI polish overhaul (MD3, dark mode, accent colors) on Ready2Blob v1.2 Researched: 2026-03-31