This is the final week of the coding period, and it went to dark mode, with a manual toggle in Settings. Most of the work was not styling. Colours in the app were written three different ways, and only one of them could ever respond to a theme.
The audit first. Before changing anything I went through how colours were actually written, and found three kinds:
bg-background, text-ink, bg-brand). These can flip, because they’re names - change what the name means and every use follows.text-gray-500, bg-gray-200, border-gray-300). These can’t. gray-500 is one specific grey, forever.Only the first kind would have responded to a toggle. Adding the switch on its own would have left grey captions, dark icons on dark backgrounds, and white headers above dark screens. So dark mode here meant rewriting the other two kinds so they could change, and only then adding the switch.
I set up five semantic tokens back in Week 1 precisely so colours would not end up scattered as hex values. That held for backgrounds, text and the brand colour, and then leaked anyway: there was no token named for secondary text or for borders, so every caption and every divider reached for a gray- class or a literal hex instead. A token system only holds if it has a name for everything you actually need.
So the palette went from five tokens to eight: background, surface, ink, subtle, line, muted, brand, and fill. subtle and line close that gap. Light values are unchanged from what the app already shipped; the dark values are Tailwind slate, matching the Figma palette.
fill closes a different gap. Several buttons used bg-ink as a black surface, treating ink as both “text colour” and “dark background” - fine while both were black, broken the moment ink flips to near-white and the white text on those buttons disappears. So ink now means text only, and fill means a dark button surface.
Two delivery paths, one palette. Everything derives from a single pair of objects in lib/theme.ts:
useColors() returns hex, for icon props, navigator options and inline stylesvars() sets CSS variables, for the className tokensThe hex-to-"r g b" conversion is computed rather than written twice, so the two representations can’t drift.
Headers are not yours to style. The bars at the top, the drawer panel, the tab bar and the status bar are drawn by the phone, so CSS variables can’t reach them. Each navigator gets real hex values from useColors() - otherwise you get a white bar sitting above a dark screen, which looks more broken than no dark mode at all.
A palette bug I caught by comparing values rather than looking at screens. Once everything was converted I put the dark values side by side and found two of them were duplicates: muted was the same as surface, and line the same as fill. Identical values mean invisible elements - the version badge, the mission and team cards, and the Help segmented control would all have vanished into their own backgrounds. Fixing muted alone would have created two new collisions, since the search-bar border and the team avatar both sit on bg-muted, so the dark values became a clean ascending slate scale: background 900, surface 800, muted 700, line and fill 600. I then checked every adjacency that actually occurs in the app rather than trusting the scale.
The toggle lives in Settings. It’s persisted to MMKV and defaults to light, with no system detection - deliberately, since that’s what was asked for.
One known gap: I don’t call Appearance.setColorScheme(), so native widgets (the Switch track, ActivityIndicator, system alerts) keep their light appearance in dark mode. Worth a follow-up.
Feature complete. Highlights, bookmarks, sharing, the Tablet of the Day hero card, dark mode and the supporting UX screens are all in.
3 months ago CDLI had no working mobile app. The iOS one had been crashing on launch since 2015 and the Android one was delisted, so this was a rebuild from an empty repository rather than a migration.
What exists now is a React Native app running on a single codebase for both platforms, talking to the live CDLI catalogue. You can browse the full set of curated tablets as a list or a grid, sort them, search across title, theme, description and author, open any tablet and read its formatted description, zoom into the imagery, bookmark what you want to come back to, and share a link that opens the right page on cdli.earth. It works offline after the first load, and it does all of that in light or dark.
Along the way the app also pushed four merge requests back into the CDLI framework itself, since two of the features needed the backend to expose data it did not previously return.
The thing I am most pleased with is not a feature. It is that almost every decision in this app has a reason I can point to, usually written down in one of these posts: why the HTML renderer is hand-written rather than a dependency, why bookmarks keep two id lists instead of one, why the daily tablet needs no storage at all. That is what makes the difference between something that gets maintained and something that gets abandoned, which is exactly what happened to the last two CDLI apps.
| Area | What I did | MR |
|---|---|---|
| Tokens | 8 light/dark tokens in lib/theme.ts, one palette driving both useColors() and vars() |
!12 |
| Conversion | Fixed grey classes and hardcoded hex replaced with tokens across the app | !12 |
fill token |
Split “text colour” from “dark button surface” so white-on-button survives the flip | !12 |
| Native chrome | Stack, Drawer, Tabs, tab bar, drawer panel and StatusBar themed via real hex | !12 |
| Toggle | Appearance section in Settings, MMKV-persisted, defaults to light | !12 |
/dl/photo/, so many can be re-uploaded through the admin panel, and a minimum resolution and file size check on the upload form would stop it recurring.Beyond that, I plan to keep contributing to CDLI after GSoC. The reason the earlier apps stopped working is that nobody maintained them, and I would rather this one not go the same way. I intend to stay around for reviews, bug fixes and the store submission, and to help whoever picks it up next.