DEV Community

Nicolás Andrés Cano Leal
Nicolás Andrés Cano Leal

Posted on

Portfolio v2.0: three themes, one headline, and every number checked against the repo

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 …
];
Enter fullscreen mode Exit fullscreen mode

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);
  });
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 new onAccent token.
  • Section wrappers with backgroundColor: '$background' hid the aurora painted on <body>. They're transparent now.
  • The Radix <Theme> paints its own background by default. It now uses hasBackground={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.sh in 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 a python:3.12-slim container. 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. 👇

nicolasandrescl.pythonanywhere.com

Top comments (0)