My portfolio just hit v2.0. It's a Django 5.2 + DRF API with a React 19 build embedded and served by Django, PostgreSQL on Neon, deployed on PythonAnywhere.
The visible change is a theme switcher in the top-right corner with three palettes: Cian (the default, a midnight background with a cyan-to-violet aurora), Violeta (light) and Ámbar (warm and sober). The less visible change taught me more: making sure my portfolio, my CV and my LinkedIn tell the same story with the same numbers.
👉 Live: nicolasandrescl.pythonanywhere.com · backend · frontend
Part 1 — A theme system that never re-renders React
Themes as plain data
Every component was already styled with Stitches tokens ($accent, $surface, $syntaxFunction…). So I didn't need a ThemeProvider. A theme is just a set of token values, written as plain data with no Stitches import:
// src/themes/palettes.js (trimmed)
export const THEMES = [
{
key: 'cian',
label: 'Cian',
dark: true,
colors: { background: '#070b14', accent: '#22d3ee', onAccent: '#04111a', /* … */ },
fonts: { heading: "'Space Grotesk', 'Segoe UI', sans-serif", /* … */ },
},
// violeta, ambar …
];
Keeping it free of Stitches means I can test it without a styling runtime. This test catches the most common theming bug, a theme that quietly inherits a color from another one because it forgot to define a token:
it('every theme defines the same tokens', () => {
const base = getTheme(DEFAULT_THEME);
const colorKeys = Object.keys(base.colors).sort();
THEMES.forEach((t) => {
expect(Object.keys(t.colors).sort()).toEqual(colorKeys);
});
});
One class on <html>
Each palette becomes a Stitches createTheme class, and switching themes just swaps a class on <html>:
export const themes = Object.fromEntries(
THEMES.map((t) => [t.key, createTheme(themeClass(t.key), { colors: t.colors, fonts: t.fonts })]),
);
Object.values(themes).forEach((t) => t.className); // ← more on this line below
export function applyTheme(key, root = document.documentElement) {
const theme = getTheme(key);
THEMES.forEach((t) => root.classList.remove(themeClass(t.key)));
root.classList.add(themeClass(theme.key));
root.dataset.theme = theme.key;
root.style.colorScheme = theme.dark ? 'dark' : 'light';
// …and updates <meta name="theme-color"> for the mobile browser bar
}
Why <html> and not a wrapper <div>? Radix renders dropdowns in a Portal attached to <body>. If the theme lived on a div inside the app, every menu would open with the wrong colors.
The switcher itself is a Radix DropdownMenu with a RadioGroup. That gives menuitemradio roles, keyboard navigation, Escape to close and focus management for free. The choice is stored in localStorage, wrapped in try/catch: private mode or blocked storage falls back to Cian instead of breaking the site.
No flash on reload
If you apply the saved theme in a useEffect, every reload paints the default theme first and then switches. The fix is a tiny inline script that runs before React loads:
<style>
html { background: #070b14; color-scheme: dark; }
html[data-theme="violeta"] { background: #fbf8ff; color-scheme: light; }
html[data-theme="ambar"] { background: #130d0a; }
</style>
<script>
(function () {
var keys = ['cian', 'violeta', 'ambar'];
var key = 'cian';
try { key = localStorage.getItem('portafolio-theme') || key; } catch (e) {}
if (keys.indexOf(key) === -1) key = 'cian';
document.documentElement.dataset.theme = key;
document.documentElement.classList.add('theme-' + key);
})();
</script>
I verified it with Playwright: save Ámbar, reload, screenshot immediately. Even the loading spinner was already amber.
Four traps I stepped on
1. Stitches injects themes lazily. A createTheme rule only reaches the stylesheet when something reads theme.className. My inline script added theme-ambar to <html> before any component had touched that theme, so the class existed but styled nothing. That's the reason for the "useless-looking" line above: reading .className for every theme when the module loads forces the injection.
2. Django wasn't serving Vite's index.html. The production page comes from Django's templates/index.html, which resolves the hashed assets from Vite's manifest. Anything I added to Vite's <head> (the anti-flash script, Google Fonts) would never reach production. It now lives in both files, and the keys have to match.
3. A light theme exposes every hard-coded color. Going from dark to light turned up three problems:
-
color: 'white'on buttons became unreadable on the bright cyan accent. That gave me a newonAccenttoken. - Section wrappers with
backgroundColor: '$background'hid the aurora painted on<body>. They'retransparentnow. - The Radix
<Theme>paints its own background by default. It now useshasBackground={false}.
4. docker compose restart served the old bundle. The built dist/ is copied into the image, so after npm run build you need up -d --build. I noticed because the index-*.js hash in the HTML hadn't changed.
Part 2 — One headline, verified numbers
While shipping the themes, I noticed something a recruiter would notice first. My portfolio said "Backend Python Developer | Automation & Reliability", my CV said "Backend Python Developer · DevOps & Reliability", and LinkedIn said something else. Three versions of the same person don't build trust.
So LinkedIn became the source of truth: Backend Engineer → SRE | Python · Django · Kubernetes · Terraform · CI/CD. The portfolio, the CV (versioned HTML → PDF via headless Edge) and my GitHub profile now say exactly that.
Then I went further and re-measured every number I publish against the actual repositories (pytest --collect-only, git shortlog, counting the Postman collection). Several were stale. The test count of my main automation project was off by thousands, a Postman collection had grown from 79 to 82 requests, and one coverage claim described a target instead of the 80% floor the CI actually enforces. I also caught my own mistake: I counted 18 releases when there were 19, because two versions only appear in the project's README, not in commit messages.
The lesson: a number on your CV is a claim someone can check. Measure it from the source, write down where it came from, and prefer wording like "2,800+ tests" when the exact count changes every week.
How it ships
- 35 frontend tests (Vitest + Testing Library) and 49 backend tests (~91% coverage) with mypy.
- A
scripts/verificar.shin each repo replays the CI on a clean clone of HEAD, with the same Node and Python versions as GitHub Actions. If Python 3.12 isn't installed locally, it runs inside apython:3.12-slimcontainer. The runner only sees what's committed, so that's what gets tested. - End-to-end check in Docker (Django + Postgres + the embedded build) with Playwright: all three themes, desktop and 375 px mobile, no horizontal scroll.
- CD with Jenkins: test gate → image build → deploy → healthcheck → smoke test, with a stage that guarantees the deploy can never point at the production database.
The two themes besides Cian were ported from an internal tool I built earlier (CmdVault), where they were NiceGUI palettes. Same idea, different stack. That's the part of side projects I enjoy most: the ideas move from one project to the next.
If you try the site, tell me which theme you'd keep. And if you have your own trick for flash-free theming, I'd love to hear it. 👇

Top comments (0)