Redesigning a live app without breaking it
Measured problems, three design directions, a retrofit onto Tailwind and shadcn/ui one screen at a time — and an accent picker that stays readable in any colour
EpisodeTracker works. What EpisodeTracker is actually built from covers the API, the database and the jobs behind it, and none of that changed here. What changed is everything a person sees: every screen, rebuilt in one day, on a site with real accounts that were using it the whole time.
The old interface was hand-written CSS — one 2,100-line stylesheet — on top of React. The new one is Tailwind CSS v4 and shadcn/ui components. The API was untouched and so was the database: this was a new coat of paint on a house people were living in.
What this covers
- Writing down what is actually wrong before designing anything
- Getting three genuinely different directions, and choosing one
- Moving from hand-written CSS to Tailwind one screen at a time, without the two fighting
- Checking the result on production with a real account, not only on localhost
- A "pick any colour" setting that cannot produce unreadable text — and the test that proves it
- The two deploy mistakes I made, so you can skip them
Start from what is wrong, not from what is ugly
"It looks dated" is not something a design can fix, because it does not say what to change. So before opening any design tool I took phone screenshots (390 px wide) of every screen on the live site, with real data, and wrote down faults I could measure:
| Fault | What it cost |
|---|---|
| About 200 px of header before any content: a logo row, a full-width search field, then a scrolling nav | On a phone, a quarter of the screen gone before the first show |
| The nav scroller clipped at the right edge | Watchlist, Discover, Suggestions, Stats and History were off-screen; most people never knew they existed |
| Three buttons of equal weight on every show: mark watched, Complete, Untrack | The destructive one was as loud as the one you press every day |
| Title, "N behind", next airing, up next, last watched — all the same size | Nothing told your eye where to look |
| Poster art from the TV data barely used | The one free, beautiful asset, wasted |
That list became the brief. Every direction had to fix all five, and a design that fixed four was not finished.
Put the real data in the brief. I gave the real show names and numbers from the screenshots — Slow Horses 13 behind, Andor 24 behind and ended, 113 episodes watched — and a hard rule: no invented features, data or social buttons. Without that, a design tool fills the gaps with things your app does not do, and you only notice when you start building them.
Three directions, not three colour schemes
I used Claude Design and asked for three directions, each covering the same five screens (dashboard, show detail, discover and search, stats and settings in light mode, and a desktop dashboard), all built from shadcn/ui parts so each one could be built as drawn.
The brief said different directions, not three colours on one layout:
| Idea | What it bet on | |
|---|---|---|
| A · Tonight | Near-monochrome zinc, one green accent kept only for "mark watched" | Speed. Dense, hairline rows, ticking episodes fast |
| B · Marquee | Amber on near-black, kept from the old app but properly art-directed | The posters. A "next airing" block at the top, poster-led cards, Oswald for titles |
| C · The Spine | The week is the page: quiet days collapse to one line, the backlog ranked by episodes behind | Structure. Editorial, serif numbers, broadcast blue |
All three solved the header the same way — a 52 px top bar and a bottom tab bar — which is a sign that it was the right answer and not a style choice.
B won. It kept the amber that existing users already knew, and it was the only one that used the poster art the app gets free from the TV data. A second round of design filled in the screens the first round did not draw: Watching, Watchlist and Completed, Suggestions, History, the public profile, login, and a sheet of states (first run, empty search, failure, offline, loading).
Retrofitting Tailwind into hand-written CSS
A stylesheet that took a year to write can't be swapped in one commit. The site had to keep working while it changed, so screens moved one at a time, each in its own commit:
bd52add Rebuild the shell and dashboard on shadcn/ui (design direction B)
632e950 Convert Watching, Watchlist and Completed to the new design
319d3f5 Convert the show detail page to the new design
03585cb Convert Discover, Suggestions, History and Stats
4736bcf Convert Settings, search and the logged-out screens
32a9007 Finish the conversion: delete index.css, Tailwind is the only stylesheet
The new pieces are ordinary shadcn/ui: Button, Badge, Card, DropdownMenu,
Input, Skeleton, copied into components/ui/ and built with cva, clsx and
tailwind-merge, with Radix underneath and lucide for icons. Tailwind v4 needs no
config file; it is one Vite plugin and one import:
// vite.config.ts
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({ plugins: [react(), tailwindcss()] });
/* styles/tailwind.css */
@import "tailwindcss";
The trap: old CSS beats new CSS
Tailwind v4 puts everything it generates inside CSS cascade layers
(@layer base, @layer utilities). The old index.css was plain CSS with no layer.
And the cascade rule is blunt: any rule outside a layer beats any rule inside one,
whatever the specificity.
So the old stylesheet's .button { background: … } beat Tailwind's bg-primary on
the same element, every time, and the new screens looked half-converted with no error
anywhere.
The fix was to put the old stylesheet in a layer of its own, declared first, so everything after it wins:
/* declared first = lowest priority */
@layer legacy, theme, base, components, utilities;
@import "./index.css" layer(legacy);
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/utilities.css" layer(utilities);
Old screens looked exactly as before; converted screens got Tailwind. Importing
Tailwind's parts separately, instead of the one @import "tailwindcss", is also what
left out its preflight (its CSS reset) during the move, because a reset
applied to screens that were never written for it breaks margins and headings
everywhere. The few resets the new shell needed were scoped to its own class.
Once the last screen moved, index.css — 2,172 lines — was deleted, the
legacy layer went with it, and preflight was switched on. tailwind.css is now the
app's only stylesheet.
Something quietly died on the way. The list/grid switch had worked by styling
.show-rowelements when[data-view="grid"]was set. The new list does not use.show-row, so the switch still toggled and did nothing — no error. It was only caught by clicking it. The newShowCardhas a real grid layout now. When you replace markup, search the old CSS for every selector that targeted it.
What the design fixed, in code
- One loud button per row. Each show has exactly one filled button, the thing you
do daily ("S4E1"). Complete, Show details and Untrack moved into a
⋯menu. - A hero for the one decision. The top of the dashboard is the show airing soonest, with the action spelled out: "Mark S4 · E1 watched".
- Bottom tabs: Shows, Watching, Discover, Suggested, More. The five daily screens are one thumb-tap away; Stats, History and Settings sit behind More.
- Search on demand, from the top bar, instead of a full-width row on every screen.
Two things I built differently from the drawings, on purpose, and said so in the commits: the Suggestions cards keep "Save for later" and "Not interested" (the design drew a Track button that screen does not have), and the login screen uses the plain layout from the second round instead of a tilted wall of posters.
Checking it on production, with a real account
Typecheck, build and the 87 API tests all passed, and a headless browser ran a 9-step click-through on localhost: mark watched, the row menu and its undo, track a show from search, tick an episode's circle, open a season, switch tabs, the More menu, switch theme. No console errors.
That still is not proof, because localhost has none of production's data or caching. So after the deploy I logged in to the live site with a throwaway audit account kept for this, which has seven real tracked shows on it so the screens look like real use. One real write: mark the hero show's next episode watched, see the count go from 18 to 19, then undo it.
After a deploy, your phone lies to you for a while. The app is an installable PWA, so the service worker keeps serving the previous build until the page has been loaded twice. "It still looks old" right after a deploy is almost always that. To take screenshots, unregister the service worker first.
"Pick any colour" without breaking readability
The first colour setting shipped six presets — amber, emerald, sky, violet, rose, lime — each checked by hand. Omar didn't like them and asked for a real picker.
The difficulty: one colour can't do the three jobs an accent does.
| Job | Needs |
|---|---|
| Button fill, with text on it | mid-light, so dark text reads on it |
| Text on the near-black theme | light enough to read on dark |
| Text on the light theme | dark enough to read on white |
Pick #1a1a1a and the buttons disappear into the dark background. Pick pale yellow
and every link on the light theme becomes invisible. Bright lime (#a3e635) reads
fine on black and gets 1.5:1 on white — WCAG requires 4.5:1.
So the picker takes any colour, keeps its hue, and works out the lightness each job
needs. theme/accentColor.ts turns one picked hex into four:
export interface AccentTokens {
raw: string; // what the user picked, shown back to them
fill: string; // button and badge fills
onFill: string; // text drawn on the fill
textDark: string; // the accent as text on the dark theme
textLight: string; // the accent as text on the light theme
}
Why OKLCH, not HSL
To make a colour lighter while keeping it "the same colour" you need a colour space where lightness is really lightness. HSL is not one of them: raise the L of a picked blue in HSL and it drifts to lilac, and two HSL colours with the same L can look very different in brightness. In OKLCH, L is perceived lightness, C is how vivid, H is the hue — change L and the hue stays put.
The steps:
- Convert the pick to OKLCH.
- Fill: clamp lightness between 0.62 and 0.86, bright enough to carry dark text
and not so bright it glares on white. Amber (
#f5a623) is already inside that band, so it passes through unchanged — a test checks that. - Text on fill: a near-black or near-white with a hint of the hue, whichever has more contrast.
- Text on each theme: walk lightness in steps of 0.02 — up for dark, down for light — until the real WCAG contrast formula says 4.5:1 or better.
- Converting back, reduce vividness until the colour fits in the screen's range (sRGB). Otherwise a very vivid hue at an extreme lightness gets clipped and the hue shifts.
function lightenUntilReadable(base, surface, from, to, min = 4.5) {
const step = to > from ? 0.02 : -0.02;
let best = oklchToHex({ ...base, l: from });
for (let l = from; step > 0 ? l <= to : l >= to; l += step) {
const hex = oklchToHex({ ...base, l });
best = hex;
if (contrast(hex, surface) >= min) return hex;
}
return best; // the end of the range is pure white/black, which always passes
}
Check against the worst surface, not the page
The first version checked accent text against the page background, #0a0a0c. It
passed. It still failed on screen — because accent text also sits on cards, menus
and muted chips, and the lightest of those on the dark theme (--muted, #231f2b)
has less contrast with light text than the page does.
So each theme checks against its worst surface: the lightest one on dark and the darkest one on light. That bug is why the constants in the file are what they are.
Greys stay grey. A pure black or grey has no hue at all, and the maths reports its hue as 0° — which is pink. An early rule that gave every colour a minimum vividness painted a "black" pick dusty pink. Now anything nearly grey stays exactly grey. The opposite limit exists too: vividness is capped at 0.2, beyond which fills glow on the light theme.
A promise that's arithmetic can be tested
"Any colour stays readable" is a claim about maths, so it doesn't need anyone to look.
accentColor.test.ts walks the RGB cube in 16 steps per channel — 4,096 colours —
and for every one asserts 4.5:1 or better for text on its own fill, on all four dark
surfaces and on all three light ones:
test("every colour on a 4,096-point sweep of the cube stays readable", () => {
for (let r = 0; r < 256; r += 17)
for (let g = 0; g < 256; g += 17)
for (let b = 0; b < 256; b += 17)
assertReadable(`#${[r, g, b].map((n) => n.toString(16).padStart(2, "0")).join("")}`);
});
It's a fixed sweep, not random, so it fails the same way on every machine. Black,
white, greys and saturated primaries get their own named tests, because those are the
colours that break a naive version. npm test at the repo root now runs the web
tests as well as the API's.
The screen, and no flash on load
Settings → Appearance has a colour well (<input type="color">, which on a phone
opens the system's full colour wheel), a hex field that accepts fa3 or #F5A623,
twelve quick picks, a live preview of what the colour became on both themes, and a
reset.
The four values are CSS custom properties on <html>, and the stylesheet falls back
to amber when none are set:
:root {
--primary: var(--accent-fill, #f5a623);
--primary-foreground: var(--accent-on-fill, #2b1a00);
--primary-text: var(--accent-text-dark, #f5a623);
--ring: var(--accent-text-dark, #f5a623);
}
Nothing re-renders to recolour: change four variables and every button, badge, link, active tab and focus ring follows.
React mounts too late to set them without the page first showing amber for a moment.
So a small inline script in index.html sets them before the first paint. The trick
is that the four derived colours are stored ready-made in localStorage, so that
script only copies them — it does not need to ship the colour maths twice:
var a = JSON.parse(localStorage.getItem("accent") || "null");
var ok = /^#[0-9a-f]{6}$/i;
if (a && ok.test(a.fill) && ok.test(a.onFill) && ok.test(a.textDark) && ok.test(a.textLight)) {
var st = document.documentElement.style;
st.setProperty("--accent-fill", a.fill);
st.setProperty("--accent-on-fill", a.onFill);
st.setProperty("--accent-text-dark", a.textDark);
st.setProperty("--accent-text-light", a.textLight);
}
The format check matters: anything written to localStorage can be edited by hand,
and a garbage value should leave the amber default, not a broken page.
The colour is saved per device, not on the account. It is a display preference,
so the API and database stayed untouched. The cost: a second phone starts on amber.
Syncing it would need one column on the User table.
Two deploy mistakes
The droplet's lockfile. The server's copy of the repo has a modified
package-lock.json, because running npm install there rewrites it. The deploy
script fast-forwards the server's checkout, so the first commit that changed the
lockfile — adding Tailwind — stopped the deploy with "cannot fast-forward — local
changes present?". The fix is to throw away the server's copy of that one file, then
deploy again:
ssh server 'cd ~/apps/episode-tracker && git checkout -- package-lock.json'
The lasting fix is to build on the Mac and ship the built output, so the server never
runs npm install at all.
Committing on the wrong branch. This repo works on develop and deploys by
fast-forwarding main. After the first deploy I stayed on main and committed the
accent work there. A pre-push hook caught it: main had a commit that develop did
not. The repair:
git checkout develop
git merge --ff-only main
git push origin develop main
After every deploy, switch back to
developstraight away. A hook that refuses the push is what turned this from a mess into a two-minute fix. If your repo has a branch rule, make a hook enforce it; you will forget.
The same week: oyware.com
The day before, the landing page at oyware.com was rebuilt on the same tools — Vite, React, Tailwind v4 and shadcn/ui, with Motion for scroll effects. It goes the opposite way from the tracker's: the page has no colour of its own. The background is limestone, the text near-black, and every colour on the page belongs to a real screenshot of a project. One font, Archivo, with hierarchy from its width axis instead of a second family.
Two lessons carried straight over from the tracker:
- A dark theme set by React flashes. Same fix: an inline script in
index.htmlsets the class before the first paint. - Screenshot a PWA through a dead service worker. The tracker's screenshot on the landing page was first captured in its old design, the day after the redesign, because the headless browser was served the cached build. Unregister, reload, then capture.
What I would do again
- Write the faults as measurements before any design. The brief is what makes the directions comparable.
- Ask for directions that differ in idea, not colour. When all three solve something the same way, that part is settled.
- Move one screen per commit, with the old stylesheet in the lowest cascade layer.
- Make promises about accessibility testable, and test every input, not a sample you picked.
- Verify on production with a real account and one real write, then undo it.