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.cssdefined 8 CSS variables on:root(light) and.darktailwind.config.jsmapped them totrading.*utility classes and setdarkMode: 'class'App.jsxtoggled thedarkclass from a Zustandthemevalue 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-themeattribute rather than a class, because four themes do not fit a binary toggle..darkkept in sync costs one line and keepsdarkMode: 'class'working. It lets the team introducedark:variants later without another migration, and acts as a safety net for anything already assuming.dark.color-schemeis 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.
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.
| Token | Purpose |
|---|---|
--bg-color | Page background (existing) |
--panel-color | Cards, panels (existing) |
--border-color | Dividers (existing) |
--text-color | Body text (existing) |
--hover-color | Hover states (existing) |
--green-color | Up / positive (existing) |
--red-color | Down / negative (existing) |
--brand-color | Brand accent (existing) |
--elevated-color | Modals, dropdowns, popovers |
--sunken-color | Table headers, code blocks, wells |
--text-strong-color | Headings - this is what replaces text-white |
--text-muted-color | Captions, labels - replaces text-gray-400/500 |
--text-inverse-color | Text on brand/accent fills |
--border-strong-color | Emphasised dividers |
--accent-color | Primary interactive |
--accent-hover-color | Accent hover |
--accent-fg-color | Text on accent |
--focus-ring-color | Keyboard focus outline |
--warn-color, --info-color | Status beyond green/red |
--chart-grid-color | Chart gridlines |
--chart-axis-color | Axis lines and tick labels |
--chart-crosshair-color | Crosshair |
--chart-up-color, --chart-down-color | Candles (may differ from semantic green/red) |
--chart-line-1..6-color | Recharts series palette |
--overlay-color | Modal backdrop |
--shadow-1, --shadow-2 | Elevation (none in dark themes, real in Daylight) |
--scrollbar-track-color, --scrollbar-thumb-color, --scrollbar-thumb-hover-color | Fixes 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.
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
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:
localStorage['te-ui'].themevia the Zustandpersistmiddleware - instant, works for anonymous visitors. (This also fixes the existing bug where theme choice never survived a reload.)usr_setting.themefor logged-in users, so the theme follows them across devices. On login the server value overwrites local; on change,PUT /api/users/me/prefsfires debounced.prefers-color-schemeas 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]);
};
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:
| File | Lines | Approach |
|---|---|---|
charts/LightweightChart.jsx | 14-43, 67-74, 121-126 | chart.applyOptions({...}) from a useEffect([tokens]) so the existing instance is re-styled, not recreated |
components/ChartView.jsx | 41-73, 106, 140 | Same |
components/EquityCurve.jsx | 30, 131-168 | Recharts: stroke on CartesianGrid/XAxis/YAxis, contentStyle on Tooltip |
components/StrategyLogicBox.jsx | 66-100 | Syntax-highlight colors become tokens |
pages/ContractNote.jsx | 234, 260-505 | ~245 lines - the largest single item. Consider splitting into its own patch if Patch 17 runs long |
pages/Dashboard.jsx | 276 | Volume 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.
| Hardcoded | Replace with | Token |
|---|---|---|
text-white | text-trading-strong | --text-strong-color |
text-gray-100, text-gray-200 | text-trading-strong | |
text-gray-300, text-gray-400 | text-trading-text | --text-color |
text-gray-500, text-gray-600 | text-trading-muted | --text-muted-color |
text-gray-700, text-gray-800, text-gray-900 | text-trading-strong | |
bg-gray-900, bg-gray-950 | bg-trading-bg | --bg-color |
bg-gray-800 | bg-trading-panel | --panel-color |
bg-gray-700 | bg-trading-hover | --hover-color |
bg-gray-50, bg-gray-100 | bg-trading-sunken | --sunken-color |
border-gray-700, border-gray-800 | border-trading-border | |
hover:bg-gray-700 | hover: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.
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.
Related
- V2.0 Implementation Plan - Patches 16-19
- User System Design -
usr_setting.themepersistence