Skip to content

feat(ui): add theme system with AMOLED Strix theme#2750

Open
RAFSuNX wants to merge 1 commit intoseerr-team:developfrom
RAFSuNX:feat/theme-system-v2
Open

feat(ui): add theme system with AMOLED Strix theme#2750
RAFSuNX wants to merge 1 commit intoseerr-team:developfrom
RAFSuNX:feat/theme-system-v2

Conversation

@RAFSuNX
Copy link
Copy Markdown

@RAFSuNX RAFSuNX commented Mar 23, 2026

Description

Adds a CSS custom property-based theming system that allows users to switch between the default dark theme and an AMOLED-optimized pure black theme (AMOLED Strix) with violet accents, via a new Appearance settings page.

How it works

The core idea is zero component-level branching. Instead of rendering alternate UI trees per theme (which bloats the diff and hurts maintainability), this PR:

  1. Defines CSS custom properties for the gray and indigo color scales under :root (default) and [data-theme='amoled-strix']
  2. Overrides Tailwind's gray-* and indigo-* scales in tailwind.config.js to reference these CSS variables
  3. Every existing Tailwind class (bg-gray-800, text-gray-400, border-gray-700, etc.) automatically adapts to the active theme

The only component changes are replacing ~11 hardcoded rgba() gradient inline styles with CSS variable equivalents (e.g., rgba(17, 24, 39, 0.47)rgb(var(--gray-900) / 0.47)), since inline styles can't be reached by Tailwind.

New files

  • src/context/ThemeContext.tsx — ThemeProvider with localStorage persistence and data-theme HTML attribute
  • src/hooks/useTheme.ts — simple useContext wrapper
  • src/components/UserProfile/UserSettings/UserAppearanceSettings/index.tsx — theme picker with visual previews
  • src/pages/profile/settings/appearance.tsx + src/pages/users/[userId]/settings/appearance.tsx — page routes

Modified files

  • src/styles/globals.css — CSS custom properties for both themes; scrollbar and sidebar gradient now use variables
  • tailwind.config.js — gray/indigo color scales mapped to CSS variables
  • src/pages/_app.tsxThemeProvider wrapper
  • src/pages/_document.tsx — inline script to prevent theme flash on page load
  • 11 components — replaced hardcoded rgba()/hex gradient values with CSS variable equivalents

AI Disclosure

This PR was authored with significant assistance from Claude Code (Anthropic's CLI tool). All code was reviewed and tested by the contributor. The approach and architecture decisions were made collaboratively.

How Has This Been Tested?

  • Docker build (docker build --build-arg COMMIT_TAG=... -t seerr .) completes successfully
  • Application starts and serves pages correctly
  • Theme switch persists across page reloads via localStorage
  • Default theme renders identically to the current develop branch (CSS variables resolve to the same Tailwind v3 defaults)
  • AMOLED theme applies pure black backgrounds, adjusted gray scale, and violet accent colors across all pages
  • No theme flash on initial load thanks to the blocking inline script in _document.tsx

Checklist:

  • I have read and followed the contribution guidelines.
  • Disclosed any use of AI (see our policy)
  • I have updated the documentation accordingly.
  • All new and existing tests passed.
  • Successful build pnpm build
  • Translation keys pnpm i18n:extract
  • Database migration (if required)

Summary by CodeRabbit

Release Notes

  • New Features

    • Added appearance settings page allowing users to customize their theme preference
    • Introduced multiple theme options (Default and AMOLED-Strix) with persistent local storage
  • Style

    • Migrated visual overlays throughout the app to CSS variable-based theming for improved consistency and maintainability

Add a CSS custom property-based theming system that allows users to
switch between the default dark theme and an AMOLED-optimized pure
black theme with violet accents.

The approach overrides Tailwind's gray and indigo color scales at the
CSS variable level, so all existing utility classes automatically adapt
to the active theme without any component-level branching. Hardcoded
rgba() gradient values in inline styles have been replaced with CSS
variable equivalents to ensure full theme coverage.

New files:
- ThemeContext provider with localStorage persistence
- useTheme hook
- Appearance settings page (profile + user routes)

Modified files:
- globals.css: CSS custom properties for both themes, scrollbar and
  sidebar gradient now use variables
- tailwind.config.js: gray/indigo scales mapped to CSS variables
- _app.tsx: ThemeProvider wrapper
- _document.tsx: inline script to prevent theme flash on load
- 11 components: replaced hardcoded rgba/hex gradient values with
  CSS variable equivalents

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@RAFSuNX RAFSuNX requested a review from a team as a code owner March 23, 2026 08:26
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 23, 2026

📝 Walkthrough

Walkthrough

This PR introduces a theme system with localStorage persistence and CSS variable-driven color management. It adds theme infrastructure (context, hooks, initialization), refactors hardcoded gradient overlays to use CSS variables, creates a user-facing appearance settings interface, and updates styling configuration to support multiple themes.

Changes

Cohort / File(s) Summary
Theme Infrastructure
src/context/ThemeContext.tsx, src/hooks/useTheme.ts, src/pages/_document.tsx, src/pages/_app.tsx
New theme context with localStorage persistence, theme resolver on document initialization, context provider integration into app tree, and custom hook for accessing theme state.
Gradient Overlay Refactoring
src/components/Blocklist/index.tsx, src/components/IssueList/IssueItem/index.tsx, src/components/RequestList/RequestItem/index.tsx, src/components/TitleCard/index.tsx, src/components/Common/Modal/index.tsx, src/components/RequestCard/index.tsx, src/components/Common/ImageFader/index.tsx
Replaced hardcoded RGBA gradient values with CSS variable-based rgb(var(--gray-*) / <alpha>) format in backdrop overlays.
Tailwind-Based Gradient Migration
src/components/CollectionDetails/index.tsx, src/components/IssueDetails/index.tsx, src/components/MovieDetails/index.tsx, src/components/TvDetails/index.tsx
Converted inline backgroundImage linear-gradient styles to Tailwind utility classes (bg-gradient-to-b from-gray-*/to-gray-*).
Appearance Settings UI
src/components/UserProfile/UserSettings/UserAppearanceSettings/index.tsx, src/components/UserProfile/UserSettings/index.tsx, src/pages/profile/settings/appearance.tsx, src/pages/users/[userId]/settings/appearance.tsx
New component and pages for theme selection UI with swatch-based theme previews and i18n support; added "Appearance" menu entry to settings navigation.
Styling Configuration
src/styles/globals.css, tailwind.config.js
Added theme-scoped CSS variables for gray/indigo colors and scrollbar/sidebar styling; extended Tailwind config with color definitions mapped to CSS variables.

Sequence Diagram

sequenceDiagram
    participant Browser
    participant _document
    participant _app
    participant ThemeProvider
    participant UserAppearanceSettings
    participant ThemeContext
    participant localStorage

    Browser->>_document: Initial page load
    _document->>localStorage: Read 'seerr-theme' from storage
    _document->>Browser: Set data-theme attribute on <html>
    Browser->>_app: Render app
    _app->>ThemeProvider: Mount with children
    ThemeProvider->>ThemeContext: Initialize theme state
    ThemeProvider->>Browser: Render theme-aware UI
    
    Browser->>UserAppearanceSettings: User visits settings
    UserAppearanceSettings->>ThemeContext: Call useTheme()
    ThemeContext-->>UserAppearanceSettings: Return current theme
    UserAppearanceSettings->>Browser: Render theme selector
    
    Browser->>UserAppearanceSettings: User clicks theme option
    UserAppearanceSettings->>ThemeContext: Call setTheme(newThemeId)
    ThemeContext->>localStorage: Update 'seerr-theme'
    ThemeContext->>Browser: Set data-theme attribute
    Browser->>Browser: Re-render with new theme CSS variables
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • gauthier-th
  • 0xSysR3ll

Poem

🐰 A theme-switching adventure so fine,
With CSS variables aligned,
From gray-800 to AMOLED glow,
The colors dance—light or dark show!
LocalStorage remembers it all,
As themes answer the user's call. 🎨

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(ui): add theme system with AMOLED Strix theme' directly and clearly summarizes the primary change: adding a new theme system with support for an AMOLED Strix theme variant. This aligns perfectly with the changeset's core objective.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (3)
src/pages/_document.tsx (1)

18-22: Inline script correctly prevents theme flash on page load.

The dangerouslySetInnerHTML static analysis warning is a false positive here—the script content is a static string literal, not user input, so there's no XSS risk.

The script correctly uses the same storage key ('seerr-theme') and attribute name ('data-theme') as ThemeContext.tsx, ensuring consistency.

Optional hardening: The script doesn't validate the localStorage value against known theme IDs before applying it. If a user manually sets an invalid value, the CSS will gracefully fall back to :root defaults, but you could add validation for defense-in-depth:

♻️ Optional: Add theme validation
-              __html: `(function(){try{var t=localStorage.getItem('seerr-theme');if(t)document.documentElement.setAttribute('data-theme',t)}catch(e){}})()`,
+              __html: `(function(){try{var t=localStorage.getItem('seerr-theme');if(t&&(t==='default'||t==='amoled-strix'))document.documentElement.setAttribute('data-theme',t)}catch(e){}})()`,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/_document.tsx` around lines 18 - 22, This inline script in
_document.tsx is a false positive for dangerouslySetInnerHTML because the
content is a static string; leave the script as-is to prevent theme flash (it
uses the 'seerr-theme' key and sets 'data-theme'), or if you prefer
defence-in-depth update the script to validate the stored value against your
known theme IDs (the same whitelist used in ThemeContext.tsx, e.g. the
allowedThemes/THEME_IDS constant) before calling setAttribute; alternatively add
a short comment above the script explaining it is static and safe to suppress
the lint warning.
src/hooks/useTheme.ts (1)

1-6: Consider adding a guard for usage outside ThemeProvider.

The hook assumes it's always used within a ThemeProvider. While the provider is at the app root, a defensive check could provide clearer error messages during development if the context is accidentally used in isolation (e.g., tests, Storybook).

♻️ Optional: Add context validation
 import { ThemeContext } from '@app/context/ThemeContext';
 import { useContext } from 'react';

-const useTheme = () => useContext(ThemeContext);
+const useTheme = () => {
+  const context = useContext(ThemeContext);
+  if (!context) {
+    throw new Error('useTheme must be used within a ThemeProvider');
+  }
+  return context;
+};

 export default useTheme;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/useTheme.ts` around lines 1 - 6, The useTheme hook currently
returns ThemeContext value without checking whether it's undefined; modify
useTheme to call useContext(ThemeContext), store the result in a local variable,
and if the result is null/undefined throw a clear error (e.g. "useTheme must be
used within a ThemeProvider") so consumers (tests/Storybook) get a helpful
message; finally return the validated context value. Reference: useTheme and
ThemeContext (and mention ThemeProvider in the error text).
src/components/UserProfile/UserSettings/UserAppearanceSettings/index.tsx (1)

27-36: Use ThemeId-keyed mapping for compile-time exhaustiveness.

Typing themeNames as Record<string, ...> allows incomplete mappings that won't surface until runtime. Use Record<ThemeId, ...> instead so missing theme entries fail at compile time. The ThemeId type is already defined in ThemeContext as 'default' | 'amoled-strix' and matches the actual themes.

This change also enables removing the optional chaining at lines 135-140 since type safety guarantees info is always present.

♻️ Proposed typing refactor
-import { THEMES } from '@app/context/ThemeContext';
+import { THEMES, type ThemeId } from '@app/context/ThemeContext';

-  const themeNames: Record<string, { name: string; description: string }> = {
+  const themeNames: Record<ThemeId, { name: string; description: string }> = {
     default: {
       name: intl.formatMessage(messages.themeDefaultName),
       description: intl.formatMessage(messages.themeDefaultDescription),
     },
     'amoled-strix': {
       name: intl.formatMessage(messages.themeAmoledStrixName),
       description: intl.formatMessage(messages.themeAmoledStrixDescription),
     },
   };

Then update the optional chaining at lines 135–140:

-                      {info?.name}
+                      {info.name}
-                      {info?.description}
+                      {info.description}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/UserProfile/UserSettings/UserAppearanceSettings/index.tsx`
around lines 27 - 36, Change the themeNames map to be keyed by the ThemeId union
so missing themes fail at compile time: replace Record<string, {name: string;
description: string}> with Record<ThemeId, {name: string; description: string}>,
import ThemeId from ThemeContext if needed, and keep the same entries for
'default' and 'amoled-strix'; then remove the optional chaining where you read
this map (the code that currently uses themeNames?.[...] or info? checks)
because ThemeId typing guarantees the entry exists. Ensure all usages of
themeNames (e.g., where info is read) compile after switching to
Record<ThemeId,...>.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/context/ThemeContext.tsx`:
- Around line 53-57: The setTheme callback currently calls
localStorage.setItem(STORAGE_KEY, next) without protection; wrap that write in a
try/catch inside setTheme (the function that calls setThemeState(next) and
document.documentElement.setAttribute('data-theme', next)) so storage errors
(private mode/quota/disabled) won’t throw—on error, swallow or log it via
console.warn/processLogger and continue to apply the theme to state and
document; keep STORAGE_KEY and setThemeState usage intact and ensure
document.documentElement.setAttribute still runs even if storage fails.

---

Nitpick comments:
In `@src/components/UserProfile/UserSettings/UserAppearanceSettings/index.tsx`:
- Around line 27-36: Change the themeNames map to be keyed by the ThemeId union
so missing themes fail at compile time: replace Record<string, {name: string;
description: string}> with Record<ThemeId, {name: string; description: string}>,
import ThemeId from ThemeContext if needed, and keep the same entries for
'default' and 'amoled-strix'; then remove the optional chaining where you read
this map (the code that currently uses themeNames?.[...] or info? checks)
because ThemeId typing guarantees the entry exists. Ensure all usages of
themeNames (e.g., where info is read) compile after switching to
Record<ThemeId,...>.

In `@src/hooks/useTheme.ts`:
- Around line 1-6: The useTheme hook currently returns ThemeContext value
without checking whether it's undefined; modify useTheme to call
useContext(ThemeContext), store the result in a local variable, and if the
result is null/undefined throw a clear error (e.g. "useTheme must be used within
a ThemeProvider") so consumers (tests/Storybook) get a helpful message; finally
return the validated context value. Reference: useTheme and ThemeContext (and
mention ThemeProvider in the error text).

In `@src/pages/_document.tsx`:
- Around line 18-22: This inline script in _document.tsx is a false positive for
dangerouslySetInnerHTML because the content is a static string; leave the script
as-is to prevent theme flash (it uses the 'seerr-theme' key and sets
'data-theme'), or if you prefer defence-in-depth update the script to validate
the stored value against your known theme IDs (the same whitelist used in
ThemeContext.tsx, e.g. the allowedThemes/THEME_IDS constant) before calling
setAttribute; alternatively add a short comment above the script explaining it
is static and safe to suppress the lint warning.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 581cc65e-f9c9-489c-9780-3419b2d3b14d

📥 Commits

Reviewing files that changed from the base of the PR and between dbe1fca and f0a2d06.

📒 Files selected for processing (21)
  • src/components/Blocklist/index.tsx
  • src/components/CollectionDetails/index.tsx
  • src/components/Common/ImageFader/index.tsx
  • src/components/Common/Modal/index.tsx
  • src/components/IssueDetails/index.tsx
  • src/components/IssueList/IssueItem/index.tsx
  • src/components/MovieDetails/index.tsx
  • src/components/RequestCard/index.tsx
  • src/components/RequestList/RequestItem/index.tsx
  • src/components/TitleCard/index.tsx
  • src/components/TvDetails/index.tsx
  • src/components/UserProfile/UserSettings/UserAppearanceSettings/index.tsx
  • src/components/UserProfile/UserSettings/index.tsx
  • src/context/ThemeContext.tsx
  • src/hooks/useTheme.ts
  • src/pages/_app.tsx
  • src/pages/_document.tsx
  • src/pages/profile/settings/appearance.tsx
  • src/pages/users/[userId]/settings/appearance.tsx
  • src/styles/globals.css
  • tailwind.config.js

Comment on lines +53 to +57
const setTheme = useCallback((next: ThemeId) => {
setThemeState(next);
localStorage.setItem(STORAGE_KEY, next);
document.documentElement.setAttribute('data-theme', next);
}, []);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify read path is guarded while write path is not
rg -n -C3 "localStorage\\.(getItem|setItem)" src/context/ThemeContext.tsx

Repository: seerr-team/seerr

Length of output: 627


Guard localStorage.setItem in setTheme to avoid runtime crashes in storage-restricted environments.

The read path is protected with try-catch, but the write path is unguarded. This inconsistency can cause theme switching to fail when localStorage throws (private browsing, quota exceeded, disabled storage, etc.).

🛡️ Proposed reliability fix
   const setTheme = useCallback((next: ThemeId) => {
     setThemeState(next);
-    localStorage.setItem(STORAGE_KEY, next);
+    try {
+      localStorage.setItem(STORAGE_KEY, next);
+    } catch {
+      // localStorage unavailable
+    }
     document.documentElement.setAttribute('data-theme', next);
   }, []);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const setTheme = useCallback((next: ThemeId) => {
setThemeState(next);
localStorage.setItem(STORAGE_KEY, next);
document.documentElement.setAttribute('data-theme', next);
}, []);
const setTheme = useCallback((next: ThemeId) => {
setThemeState(next);
try {
localStorage.setItem(STORAGE_KEY, next);
} catch {
// localStorage unavailable
}
document.documentElement.setAttribute('data-theme', next);
}, []);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/context/ThemeContext.tsx` around lines 53 - 57, The setTheme callback
currently calls localStorage.setItem(STORAGE_KEY, next) without protection; wrap
that write in a try/catch inside setTheme (the function that calls
setThemeState(next) and document.documentElement.setAttribute('data-theme',
next)) so storage errors (private mode/quota/disabled) won’t throw—on error,
swallow or log it via console.warn/processLogger and continue to apply the theme
to state and document; keep STORAGE_KEY and setThemeState usage intact and
ensure document.documentElement.setAttribute still runs even if storage fails.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant