Skip to content

feat: add Auto/Light/Dark theme support (fixes #1702)#2496

Draft
kushmunjal wants to merge 3 commits into
ipfs:mainfrom
kushmunjal:fix-dark-mode
Draft

feat: add Auto/Light/Dark theme support (fixes #1702)#2496
kushmunjal wants to merge 3 commits into
ipfs:mainfrom
kushmunjal:fix-dark-mode

Conversation

@kushmunjal

Copy link
Copy Markdown

Fixes #1702

Summary

This adds a three-way theme setting — Auto, Light, and Dark — to ipfs-webui.
Auto follows the OS-level prefers-color-scheme and updates live if the OS
setting 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) — tracks
    the active theme, listens for OS-level scheme changes when set to Auto,
    persists the user's explicit choice to localStorage.
  • TopBarThemeToggle and ThemeSelector components for switching the theme
    from the top bar and from Settings respectively.
  • Removed hardcoded inline colors across [Box, ProgressBar, SelectedActions,
    FileImportStatus, Breadcrumbs, PublishModal, modal.tsx, App.js header,
    NodeBandwidthChart, Speedometer, WorldMap] in favor of theme-aware CSS
    classes/custom properties.
  • Fixed a pre-existing bug where the Settings page rendered a raw
    themeDescription i18n key instead of translated text.
  • World map on the Peers page uses a CSS filter to invert/desaturate the
    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-components package 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>
  • [ ✓ ] Manually verified every screen (Status, Files, Peers, Settings,
    Diagnostics) plus modals/dropdowns/selection states in both Light and
    Dark, including toggling OS-level dark mode live for Auto
  • [ ✓ ] Confirmed package.json/package-lock.json have no unrelated
    dependency changes

Open questions / things I'd appreciate a second pair of eyes on

  • [Fill in honestly — e.g. "I'm not 100% sure the background prop I'm
    passing into @tableflip/react-dropdown is officially supported by that
    library 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."]
  • [Any other genuine uncertainty — better to name it than have a reviewer
    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.

@kushmunjal
kushmunjal requested a review from a team as a code owner July 12, 2026 19:59

@lidel lidel left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-scheme listener) 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'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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' }}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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'>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tiny one: joyride-settings-theme is set both here and on ThemeSelector's root div.

One of the two is enough. Also, the white triangle in Joyride popups on every page:

Image

Comment thread src/index.css
--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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Image

Comment thread src/files/file/File.js

if (focused || (selected && !translucent) || coloured || (isOver && canDrop)) {
styles.backgroundColor = '#F0F6FA'
className += ' file-row-selected'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/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; }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Image

Comment thread src/index.css
[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; }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Image

@lidel
lidel marked this pull request as draft July 15, 2026 20:20
@lidel lidel added the status/blocked Unable to be worked further until needs are met label Jul 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status/blocked Unable to be worked further until needs are met

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: dark mode

2 participants