Skip to main content

#501 Into the Darkness

Friday Ship #501 | July 24th, 2026

Parabol Estimate Meeting Dark Mode Screenshot

This week we shipped Dark Mode into production.

Dark mode has been a feature first requested in 2024, directly on our backlog by a Parabol user. Other users echoed this request. Finally with the power of Claude (and Claude’s newest model, Fable) we were able to shape and ship this feature.

Our first step was to use Claude Design Beta in order to create a component library of each and component in our default (light mode) theme. Then, we had Claude Design create multiple dark-mode variants for Parabol’s core experiences (our task dashboard, Pages, Retrospectives, Stand-Ups, and Sprint Poker meetings). From these variants we down selected to 1 palette that was the clear winner and wrote spec that defined how to perform this work in 7 human-reviewable change sets.

Along the way, we found corner cases that needed to be addressed…but in the end this feature took under two weeks from concept to delivery. A truly stunning pace, considering that over 500 files were touched. But why weren’t we able to easily deliver dark mode before? Well, let us take share a bit of technical history…

A decade of styling Parabol

Parabol’s first stylesheet shipped in January 2016 as SASS compiled through PostCSS. It contained a mixin that mapped five brand colors to letters and generated atomic utility classes — .tu-a-bg, .tu-a-bg-10a for a 10% alpha variant. We hand-rolled Tailwind seven years before it existed, then deleted it four months later.

That deletion came in PR #49, April 2016: “Stripped LESS & SASS from project. Styles moved from SASS to JS.” Styles went inline via react-look, then Aphrodite that October, then Emotion in February 2018, where 409 files still live today. The runtime CSS-in-JS era brought a two-layer theme: a brand.js of literal hex, and a palette.js that abstracted it into cool, warm, dark, mid, light, with alpha and mix variants computed at import time by tinycolor.

In September 2018 we had moved to a design token system. paletteV2 held roughly 120 role-named constants: BACKGROUND_MAIN, BORDER_FIELD_FOCUS, CONTROL_MAIN_BACKGROUND, TEXT_FIELD_DARK. Finally we had solved semantic styling in our app. Other themes should be a a piece of cake!

Then in March 2021 we unsolved it, on purpose. PR #4776 introduced paletteV3: hue-and-weight scales — tomato, gold, slate, grape, sky, jade, each 100 through 900 — mirroring a Figma file cited by URL in the header comment.

That decision paid off almost immediately. When Tailwind landed in December 2022, wiring it up took one commit, because paletteV3 was already exactly the shape a Tailwind theme expects. slate-200 and grape-700 drop straight in. The refactor that cost us our semantic layer is the same refactor that made Tailwind adoption nearly free — literal color names are the ideal interface between Figma and Tailwind but the worst possible interface for a themeable app. We bought three years of velocity against a debt that only came due when the app needed a second theme.

Moving Parabol (again) to semantic styling

We’ve restored 87 role tokens — surface-app, surface-card, fg-primary, fg-muted, hairline, accent. Substantially the same vocabulary paletteV2 had, thirty months after we deleted it.

What changed is the layer, and that’s the part that matters. paletteV2 was a TypeScript const enum: inlined at build time, one value per token, forever. The new tokens are CSS custom properties defined against the V3 literals rather than replacing them:

@theme      { --color-surface-card: var(--color-white); }
.theme-dark { --color-surface-card: var(--color-grape-750); }

The browser resolves custom properties as it paints, and their values inherit down the DOM. So –color-surface-card isn’t a color — it’s a lookup that happens fresh on every element, every render. Change it once on <html> and every component below reads the new value. That’s the entire theme switch.

paletteV2 had the right names but no lookup. A TypeScript const enum is inlined by the compiler, so PALETTE.BACKGROUND_MAIN became the literal string '#F1F0FA' in the bundle. The semantic name existed in the source and nowhere in the shipped code — there was nothing left at runtime to point somewhere else.

That’s the lesson worth keeping. A semantic token isn’t a naming convention; it’s a pointer. If your build step resolves the pointer, you shipped the color, not the token.

What’s next for Parabol’s component styling

We’re not done. Of 1,274 client components, 571 use Tailwind classes, 409 still import Emotion, and 131 still reach past the semantic layer into paletteV3 directly. When we have a bit more refactoring time, we’ll get this cleaned up to.

But for now, at least we have dark mode!

Metrics

Metrics for Parabol Friday Ship #501, 3 graphs and 3 tabular data tables

There were small gains on top of funnel and weekly meetings ran, while the overall number of MAU declined slightly.

This week we…

…performed some platform maintenance. Node was bumped to v24.17.0, security dependency updates went out, a race condition when re-joining an organization was fixed, and outbound SMTP is now sent in batches instead of one large blast.

…made Pages search respect the trash. Trashed pages and items no longer pollute search results, with an opt-ind trash filter in the search dialog and server-side test coverage for the new behavior.

Next week we’ll

…bring Team Health to life end-to-end. The schema, seed templates, start/end mutations, and team settings are wired up; the remaining work is meeting scheduling and the recurring-series experience.

Jordan Husney

Jordan Husney

Jordan is Parabol’s CEO and Head of Product. He was previously a Director at Undercurrent, where he advised C-Suite teams of Fortune 100 organizations on the future of work. Jordan has an engineering background, holding several patents in distributed systems and wireless technology. Jordan lives in Minneapolis, MN.

All your agile meetings in one place

Run efficient meetings, get your team talking, and save time. Parabol is free for up to 2 teams.