feat: add Auto/Light/Dark theme support (fixes #1702)#2496
Conversation
lidel
left a comment
There was a problem hiding this comment.
Thanks for picking this up @kushmunjal, and for reading the history around #2322 before starting. CSS variables with [data-theme] overrides is the piece the previous attempt was missing, and shared surfaces get themed the right way here. 👍
Because the last attempt merged and then had to be reverted (#2339), I'm extra cautious about quality here: we don't want to ship a half-baked solution again. As noted in #1702 (comment), dark mode has to include the Explore screen. The overrides in index.css are global, so they also restyle what ipld-explorer-components renders there: the page comes out partially themed, which is the exact failure mode that caused the revert. Please open a companion PR in https://github.com/ipfs/ipld-explorer-components/ (or add an explicit opt-out here until it lands).
I left inline comments with specifics. The two most user-visible: light mode lost the header background (.webui-header has no light rule), and the Settings section title renders a raw theme i18n key.
Smaller asks:
- The "Open questions" section of the PR description still contains template placeholders; please replace them with your notes.
- Some light-mode colors changed as a side effect (Box, modal action bar, progress bar, file import header, selected rows). If intentional, say so in the description; if not, the CSS variables can keep the original values.
- A unit test for the theme context (localStorage persistence and the
prefers-color-schemelistener) would help keep this from regressing. - Include screenshots of all screens in the PR description rather than in issue comments like #1702 (comment), so they stay next to the change they document.
| const getButtonClassName = ({ fill, bg, color, danger, disabled }: Pick<ButtonProps, 'fill' | 'bg' | 'color' | 'danger' | 'disabled'>, type: ButtonProps['type']) => { | ||
| if (danger) return 'bg-red fill-white white' | ||
| if (disabled) return 'bg-gray-muted fill-snow light-gray' | ||
| if (disabled) return 'bg-black-10 fill-charcoal-muted charcoal-muted' |
There was a problem hiding this comment.
iiuc this changes disabled buttons in light mode too, and bg-black-10 is close to invisible on a dark background. Consider keeping the old classes and adding a dark override for .bg-gray-muted in index.css instead.
| <div> | ||
| <Trans i18nKey='notConnected.paragraph3' t={t}> | ||
| <li className='mb3 mt4'>Is your Kubo RPC API configured to allow <a className='link blue' href='https://github.com/ipfs/ipfs-webui#configure-kubo-rpc-api-cors-headers'>cross-origin (CORS) requests</a>? If not, run these commands and then start your daemon from the terminal:</li> | ||
| <li className='mb3'>Is your Kubo RPC API configured to allow <a className='link blue' href='https://github.com/ipfs/ipfs-webui#configure-kubo-rpc-api-cors-headers'>cross-origin (CORS) requests</a>? If not, run these commands and then start your daemon from the terminal:</li> |
There was a problem hiding this comment.
The margin changes in this file look unrelated to the theme work. Mind reverting them, or explaining why they are needed?
|
|
||
| return ( | ||
| <div className='joyride-settings-theme'> | ||
| <Title>{t('theme') || 'Theme'}</Title> |
There was a problem hiding this comment.
There is no theme key in settings.json, and i18next returns the key itself when one is missing, so this title renders as literal "theme" (the || 'Theme' fallback never runs; same for the themeDescription fallback below). Please add the key, and use t() for the Auto/Light/Dark labels too so they can be translated.
| className="button-reset bg-transparent bn p0 m0 mr3 flex items-center justify-center pointer charcoal-muted hover-navy transition-all glow" | ||
| onClick={cycleTheme} | ||
| title={t('themeDescription')} | ||
| style={{ width: 28, height: 28, outline: 'none' }} |
There was a problem hiding this comment.
ux nit: outline: 'none' hides the focus ring for keyboard users, and the button has no text for screen readers.
Please use the existing focus-outline classes instead, and add an aria-label naming the current theme.
| */ | ||
| export const ThemeProvider = ({ children }) => { | ||
| const [theme, setThemeState] = useState(() => { | ||
| return localStorage.getItem('ipfs-webui-theme') || DEFAULT_THEME |
There was a problem hiding this comment.
Any string in localStorage becomes theme unchecked, and the Settings select renders blank for unknown values. localStorage access can also throw in private browsing / tor browser under certain modes
Please validate against ['auto', 'light', 'dark'] and wrap localStorage access in try/catch that defaults to auto on unknown value
| <LanguageSelector t={t} /> | ||
| </div> | ||
|
|
||
| <div className='mt4 joyride-settings-theme'> |
| --publish-modal-gradient: linear-gradient(90deg, rgba(31, 38, 46, 0.35), var(--element-bg) 10%, var(--element-bg) 100%); | ||
| --breadcrumb-gradient: linear-gradient(to right, var(--element-bg) 0%, transparent 100%); | ||
| --gray-muted: #d9dbe2; | ||
| --charcoal: #c8c3bc; |
There was a problem hiding this comment.
From testing the branch in dark mode: on the Status page welcome box, the body text and the link blue links are hard to read (see screenshot). Contrast this low is an accessibility problem, WCAG AA expects 4.5:1 for text.
The text comes from charcoal and inherited colors in IsConnected.js, AboutWebUI.js and AboutIpfs.js, so the fix belongs here in the dark palette: brighter --charcoal / --charcoal-muted values, plus a dark override for .link.blue. Running the final values through a contrast checker would confirm AA.
|
|
||
| if (focused || (selected && !translucent) || coloured || (isOver && canDrop)) { | ||
| styles.backgroundColor = '#F0F6FA' | ||
| className += ' file-row-selected' |
There was a problem hiding this comment.
From testing in dark mode: the separators between file rows come out almost white and very bright. They should be dark grey, like the other borders.
Two sources: the row root above uses b--light-gray (which has no dark override), and further down this file sets an inline borderTop: '1px solid #eee', which no CSS override can reach. Suggest dropping the inline border in favor of the class, and adding [data-theme="dark"] .b--light-gray { border-color: var(--border-color) !important; } in index.css.
| [data-theme="dark"] .bg-near-white { background-color: var(--snow-muted) !important; } | ||
| [data-theme="dark"] .bg-light-gray { background-color: var(--snow-muted) !important; } | ||
| [data-theme="dark"] .bg-white-70 { background-color: var(--element-bg) !important; opacity: 1 !important; } | ||
| [data-theme="dark"] .charcoal { color: var(--charcoal) !important; } |
There was a problem hiding this comment.
From testing in dark mode: the view-toggle and search icons next to the sort dropdown on the Files page are nearly invisible, navy on near-black (see screenshot). Many people will simply not see them, so this is an accessibility problem too.
They get their color from .selected-item (color: #0b3a53 in file-thumbnail.css) via fill="currentColor". One more override next to the text colors here, e.g. [data-theme="dark"] .selected-item { color: var(--charcoal) !important; }, makes them visible again.
| [data-theme="dark"] .webui-header { background-color: var(--snow-muted) !important; } | ||
|
|
||
| /* Tachyons borders */ | ||
| [data-theme="dark"] .b--black-20 { border-color: var(--border-color) !important; } |
There was a problem hiding this comment.
From testing in dark mode: on the Diagnostics screen, the line under the Logs / Retrieval Check / DHT Provide tabs stays white (see screenshot).
It is a hardcoded inline borderBottom: '1px solid #e1e5eb' in diagnostics-content.tsx, so it needs a small change in that file: reuse the same border color inputs get in dark mode (var(--border-color), as in the b--black-20 override here) instead of the fixed light value.




Fixes #1702
Summary
This adds a three-way theme setting — Auto, Light, and Dark — to ipfs-webui.
Auto follows the OS-level
prefers-color-schemeand updates live if the OSsetting changes; Light/Dark are manual overrides, persisted across sessions.
The toggle is available in two places: a compact icon in the top bar for
quick switching, and a full selector on the Settings page.
Why I approached it this way
I know dark mode was attempted once before in #2322 and reverted in #2339
after several screens/modals were found unreadable post-merge. I read through
that history before starting, and tried to design around the specific gap
that caused the revert — rather than hand-editing individual components, I
introduced CSS custom properties for color (
--element-bg,--text,--border-color, etc., swapped per theme via a[data-theme="dark"]selector) so shared utility classes and components get themed centrally, and
went through every screen and every modal/dropdown/menu I could find, not
just the main page layouts.
What changed
ThemeContext/ThemeProvider(src/contexts/theme-context.js) — tracksthe active theme, listens for OS-level scheme changes when set to Auto,
persists the user's explicit choice to localStorage.
TopBarThemeToggleandThemeSelectorcomponents for switching the themefrom the top bar and from Settings respectively.
FileImportStatus, Breadcrumbs, PublishModal, modal.tsx, App.js header,
NodeBandwidthChart, Speedometer, WorldMap] in favor of theme-aware CSS
classes/custom properties.
themeDescriptioni18n key instead of translated text.static map image for dark mode rather than shipping a second image asset.
Explicitly out of scope for this PR
The Explore/DAG page's card styling is rendered by the separate
ipld-explorer-componentspackage and needs its own companion PR there(same pattern as the original #2322 effort) — I'm treating that as a
follow-up rather than bundling it in here, since it's a different repo with
its own release cycle.
Testing
npm run lint— passing as of <83d9e2b>npm run test:unit— passing as of <83d9e2b>npm run test:e2e— passing as of <83d9e2b>Diagnostics) plus modals/dropdowns/selection states in both Light and
Dark, including toggling OS-level dark mode live for Auto
package.json/package-lock.jsonhave no unrelateddependency changes
Open questions / things I'd appreciate a second pair of eyes on
backgroundprop I'mpassing into
@tableflip/react-dropdownis officially supported by thatlibrary vs. just being ignored — I confirmed via devtools that it renders
correctly, but wanted to flag the assumption in case there's a cleaner way."]
find it, especially given this feature's history.]
Given the history on this issue, I'd rather get feedback early and iterate
than have this merge and need another revert — happy to adjust scope,
approach, or split this into smaller PRs if that's easier to review.