Skip to main content

Theme System

TradeEntry v2.0 ships four selectable themes. This page is the reference for the engine, the palettes, and the de-hardcoding sweep that makes a light theme possible.

Starting point

Before v2.0 there was a single dark palette plus a broken light mode:

  • frontend/src/index.css defined 8 CSS variables on :root (light) and .dark
  • tailwind.config.js mapped them to trading.* utility classes and set darkMode: 'class'
  • App.jsx toggled the dark class from a Zustand theme value defaulting to 'dark'
  • The store had no persistence, so every reload reverted to dark
  • Zero dark: variant classes existed anywhere in the codebase

Light mode was effectively unusable: roughly 237 hardcoded text-white and gray utility classes across 17 files render white text on a #F3F4F6 background. The scrollbar rules were hardcoded dark regardless of theme.

Mechanism: data-theme, with .dark kept in sync

useEffect(() => {
const root = document.documentElement;
root.setAttribute('data-theme', theme); // 'midnight' | 'deep-ocean' | ...
root.classList.toggle('dark', THEMES[theme].scheme === 'dark');
root.style.colorScheme = THEMES[theme].scheme; // native controls follow
}, [theme]);

Three deliberate choices:

  • data-theme attribute rather than a class, because four themes do not fit a binary toggle.
  • .dark kept in sync costs one line and keeps darkMode: 'class' working. It lets the team introduce dark: variants later without another migration, and acts as a safety net for anything already assuming .dark.
  • color-scheme is the free win that makes native scrollbars, <select> dropdowns and date pickers follow the theme.

CSS structure:

@layer base {
:root, [data-theme='midnight'] { /* tokens */ }
[data-theme='deep-ocean'] { /* tokens */ }
[data-theme='carbon-amber'] { /* tokens */ }
[data-theme='daylight'] { /* tokens */ }
}

:root doubles as the Midnight default so there is no unstyled flash before React mounts.

A pre-paint script is required

Without it, every reload flashes Midnight before switching to the user's chosen theme. Add ~5 lines to frontend/index.html <head>, before any stylesheet:

<script>
try {
var s = JSON.parse(localStorage.getItem('te-ui') || '{}');
var t = (s.state && s.state.theme) || 'midnight';
document.documentElement.setAttribute('data-theme', t);
} catch (e) {}
</script>

This is the difference between "themes work" and "themes feel broken".

Token set

The existing 8 variable names are kept verbatim so nothing currently working breaks. Twenty more are added.

TokenPurpose
--bg-colorPage background (existing)
--panel-colorCards, panels (existing)
--border-colorDividers (existing)
--text-colorBody text (existing)
--hover-colorHover states (existing)
--green-colorUp / positive (existing)
--red-colorDown / negative (existing)
--brand-colorBrand accent (existing)
--elevated-colorModals, dropdowns, popovers
--sunken-colorTable headers, code blocks, wells
--text-strong-colorHeadings - this is what replaces text-white
--text-muted-colorCaptions, labels - replaces text-gray-400/500
--text-inverse-colorText on brand/accent fills
--border-strong-colorEmphasised dividers
--accent-colorPrimary interactive
--accent-hover-colorAccent hover
--accent-fg-colorText on accent
--focus-ring-colorKeyboard focus outline
--warn-color, --info-colorStatus beyond green/red
--chart-grid-colorChart gridlines
--chart-axis-colorAxis lines and tick labels
--chart-crosshair-colorCrosshair
--chart-up-color, --chart-down-colorCandles (may differ from semantic green/red)
--chart-line-1..6-colorRecharts series palette
--overlay-colorModal backdrop
--shadow-1, --shadow-2Elevation (none in dark themes, real in Daylight)
--scrollbar-track-color, --scrollbar-thumb-color, --scrollbar-thumb-hover-colorFixes the hardcoded dark scrollbar

Every one is registered in tailwind.config.js under colors.trading.*, giving bg-trading-elevated, text-trading-strong, text-trading-muted, bg-trading-accent, and so on.

The sweep is impossible until these utilities exist

Patch 16 (the engine) strictly precedes Patches 18 and 19 (the sweep). There is no text-trading-strong to migrate text-white to until the token is registered.

Palettes

Midnight — the existing dark theme, preserved exactly

bg #0B0E14 panel #151924 elevated #1C2130 sunken #0F131C
border #2B3139 border-strong #3A4150 hover #2A2E39
text #B7BDC6 text-strong #EAECEF text-muted #767F8C text-inverse #0B0E14
brand #FCD535 accent #FCD535 accent-hover #E9C22B accent-fg #0B0E14
green #00C087 red #F6465D warn #F0B90B info #4A9EFF focus #FCD535
chart-grid #1E2430 chart-axis #4A5261 chart-crosshair #6B7280
chart-line 1-6: #FCD535 #00C087 #4A9EFF #F6465D #A78BFA #F59E0B
scrollbar: track #0B0E14 thumb #2B3139 thumb-hover #4B5159

Values are byte-identical to the current index.css dark block, so existing screens are visually unchanged by the engine swap. Verify with a before/after screenshot diff of one page.

Deep Ocean — navy and teal

bg #071018 panel #0D1B26 elevated #123040 sunken #050C12
border #1E3A4C border-strong #2A5165 hover #16394A
text #A8C0CC text-strong #E6F1F5 text-muted #6E8B99 text-inverse #071018
brand #2DD4BF accent #2DD4BF accent-hover #14B8A6 accent-fg #04121A
green #34D399 red #FB7185 warn #FBBF24 info #38BDF8 focus #2DD4BF
chart-grid #122A38 chart-axis #4A6B7C chart-crosshair #7B98A8
chart-line 1-6: #2DD4BF #38BDF8 #A78BFA #FB7185 #FBBF24 #86EFAC
scrollbar: track #071018 thumb #1E3A4C thumb-hover #2A5165

Carbon Amber — terminal

bg #0A0A0A panel #141414 elevated #1E1E1E sunken #050505
border #2A2A2A border-strong #3D3D3D hover #242424
text #C9C4BC text-strong #F5F0E8 text-muted #7A756D text-inverse #0A0A0A
brand #FFB000 accent #FFB000 accent-hover #E09600 accent-fg #0A0A0A
green #7FD962 red #FF5F56 warn #FFB000 info #6FA8DC focus #FFB000
chart-grid #1C1C1C chart-axis #4A4A4A chart-crosshair #6E6E6E
chart-line 1-6: #FFB000 #7FD962 #6FA8DC #FF5F56 #C792EA #E5C07B
scrollbar: track #0A0A0A thumb #2A2A2A thumb-hover #3D3D3D

Pair with a monospace-leaning stack for numeric and table cells (--font-numeric) to sell the terminal feel.

Daylight — true light

bg #F5F6F8 panel #FFFFFF elevated #FFFFFF sunken #EDEFF3
border #DDE1E7 border-strong #C3C9D2 hover #E9ECF1
text #3B4351 text-strong #121821 text-muted #6B7482 text-inverse #FFFFFF
brand #E0A800 accent #1F6FEB accent-hover #1858C4 accent-fg #FFFFFF
green #00A06E red #D93B4E warn #B7791F info #1F6FEB focus #1F6FEB
chart-grid #E6E9EE chart-axis #98A1AE chart-crosshair #6B7482
chart-line 1-6: #1F6FEB #00A06E #D93B4E #B7791F #7C3AED #0891B2
scrollbar: track #F5F6F8 thumb #C3C9D2 thumb-hover #98A1AE
Daylight's semantic colors are deliberately darker

The dark themes' #00C087 and #F6465D score 2.4:1 and 3.6:1 as text on white - both fail WCAG AA. Daylight uses #00A06E and #D93B4E instead.

Likewise #FCD535 is unreadable on white, so --brand-color becomes #E0A800 for text use and --accent-color becomes blue.

Daylight also has elevated == panel; separation comes from --shadow-1/--shadow-2, which are none in the three dark themes.

Contrast audit is a Patch 16 acceptance criterion. Every text token against its intended surface must reach 4.5:1, and --border-color against --panel-color at least 3:1. Attach the audit table to the PR.

Persistence

Three layers, in priority order:

  1. localStorage['te-ui'].theme via the Zustand persist middleware - instant, works for anonymous visitors. (This also fixes the existing bug where theme choice never survived a reload.)
  2. usr_setting.theme for logged-in users, so the theme follows them across devices. On login the server value overwrites local; on change, PUT /api/users/me/prefs fires debounced.
  3. prefers-color-scheme as the first-ever-visit default - dark maps to Midnight, light to Daylight.

Theme picker

The Sun/Moon toggle in Header.jsx becomes a <ThemePicker/> dropdown: four rows, each showing the theme name and a four-swatch preview strip (bg / panel / accent / green) rendered from the actual token values, with a check on the active theme. Applies on hover for live preview, commits on click, reverts on dismiss.

Use the Palette icon from lucide-react, already a dependency. Surface the same component in the Profile page's Appearance tab.

Chart tokens

Charts cannot use Tailwind classes - they take hex strings in JS config objects. frontend/src/theme/tokens.js:

export const readToken = (name) =>
getComputedStyle(document.documentElement).getPropertyValue(`--${name}`).trim();

export const chartTokens = () => ({
grid: readToken('chart-grid-color'),
axis: readToken('chart-axis-color'),
up: readToken('chart-up-color'),
down: readToken('chart-down-color'),
series: [1,2,3,4,5,6].map(i => readToken(`chart-line-${i}-color`)),
text: readToken('text-color'),
panel: readToken('panel-color'),
});

export const useChartTokens = () => {
const theme = useUiStore(s => s.theme);
return useMemo(() => chartTokens(), [theme]);
};
The useMemo dependency is the whole trick

Keying on theme means getComputedStyle runs after the data-theme attribute changed, so tokens are always current - and the dependency forces every chart to re-render on theme switch.

Per file:

FileLinesApproach
charts/LightweightChart.jsx14-43, 67-74, 121-126chart.applyOptions({...}) from a useEffect([tokens]) so the existing instance is re-styled, not recreated
components/ChartView.jsx41-73, 106, 140Same
components/EquityCurve.jsx30, 131-168Recharts: stroke on CartesianGrid/XAxis/YAxis, contentStyle on Tooltip
components/StrategyLogicBox.jsx66-100Syntax-highlight colors become tokens
pages/ContractNote.jsx234, 260-505~245 lines - the largest single item. Consider splitting into its own patch if Patch 17 runs long
pages/Dashboard.jsx276Volume bar rgba values

Acceptance: switching theme with a chart on screen restyles it without remount - no flicker, no data refetch. Watch the Network tab. grep -E "#[0-9a-fA-F]{6}" returns 0 in these files.

The de-hardcoding sweep

237 occurrences across 17 files. A 237-line diff is only reviewable if reviewers check the mapping, not each line.

Step 1 — the mapping table

Every replacement must use one of these substitutions. Anything needing something else is left alone and listed as an exception in the PR description for discussion.

HardcodedReplace withToken
text-whitetext-trading-strong--text-strong-color
text-gray-100, text-gray-200text-trading-strong
text-gray-300, text-gray-400text-trading-text--text-color
text-gray-500, text-gray-600text-trading-muted--text-muted-color
text-gray-700, text-gray-800, text-gray-900text-trading-strong
bg-gray-900, bg-gray-950bg-trading-bg--bg-color
bg-gray-800bg-trading-panel--panel-color
bg-gray-700bg-trading-hover--hover-color
bg-gray-50, bg-gray-100bg-trading-sunken--sunken-color
border-gray-700, border-gray-800border-trading-border
hover:bg-gray-700hover:bg-trading-hover
text-black (on brand fills)text-trading-inverse--text-inverse-color

Step 2 — mechanical, one file per commit

No other changes in the same commit. Reviewers verify with git show --stat (only class strings changed, no JSX structure) plus a per-file git diff --word-diff.

Step 3 — visual verification

Each swept file gets a screenshot in all four themes attached to the PR. Cheap, and it catches the two failure modes a diff cannot: white-on-white in Daylight, and lost visual hierarchy where a text-white was doing emphasis work that text-trading-strong under-delivers.

Step 4 — a guard so it never regresses

Add to frontend/eslint.config.js (or an npm run lint:theme script) a no-restricted-syntax rule flagging text-white|text-gray-\d|bg-gray-\d in JSX className literals.

Without the guard, the 237 come back within two sprints

This step is not optional. It is what makes the sweep a one-time cost rather than a recurring one.

File split

The 17 files, split across two patches roughly balanced by occurrence count:

Patch 18 — heavy pages (4 files, ~126 occurrences) BhavDownloader.tsx (~54), ContractNote.jsx (~28), Dashboard.jsx (~22), HolidayMaster.jsx (~22)

Patch 19 — remaining (13 files, ~111 occurrences) ChartView.jsx, CsvUploader.jsx, EquityCurve.jsx, Header.jsx, StrategyForm.jsx, StrategyLogicBox.jsx, Analytics.jsx, AssetAllocation.jsx (~11), EodieodUpload.jsx, Home.jsx, IeodSpotFut.jsx, OptionChain.jsx, StrategyBuilder.jsx

Final acceptance: repo-wide grep returns 0, down from 237:

grep -rE "text-white|text-gray-[0-9]{3}|bg-gray-[0-9]{3}" frontend/src --include=*.jsx --include=*.tsx

Loose end worth fixing here

tailwind.config.js declares fontFamily.sans: ['Inter', 'system-ui', 'sans-serif'], but Inter is never loaded - there is no @font-face and no Google Fonts link in index.html. Every page currently renders in system-ui. Add the font link in Patch 16 so the declared font actually applies.