Skip to content

fix(themes): prevent reset on license errors, improve switch performance - #844

Merged
s0up4200 merged 3 commits into
mainfrom
fix/premium-theme-validation
Dec 22, 2025
Merged

fix(themes): prevent reset on license errors, improve switch performance#844
s0up4200 merged 3 commits into
mainfrom
fix/premium-theme-validation

Conversation

@s0up4200

@s0up4200 s0up4200 commented Dec 22, 2025

Copy link
Copy Markdown
Collaborator

Summary

  • Adds 7-day grace period for license validation to prevent premium theme resets on transient API failures (fixes Fix/premium theme resets to default after idle time #837)
  • Caches license entitlement in localStorage for offline/error resilience
  • Reduces license API polling from 30s to 1h with retry support
  • Blocks premium theme switching during errors unless within grace period
  • Shows warning banner when license verification is unavailable
  • Drastically improves theme switch performance by removing heavy CSS transitions
  • Makes font loading non-blocking
  • Coalesces PWA theme updates via requestAnimationFrame

Test plan

  • Verify premium theme persists when license API is temporarily unavailable
  • Verify premium theme switching is blocked during API errors (unless within grace)
  • Verify theme switching feels instant (no lag)
  • Verify warning banner appears when license check fails
  • Verify license deletion clears cached entitlement

Summary by CodeRabbit

  • New Features

    • PWA mobile app capability enabled for improved mobile experience.
    • License-aware premium theme gating with offline grace-period persistence and clearer verification handling.
    • Theme preview caching and variation previews in the theme picker.
  • Improvements

    • More descriptive error messaging, warning banner when verification is unavailable, and gated premium actions.
    • Debounced theme update scheduling to reduce redundant updates.
    • Shorter, optional theme transitions (150ms) and non-blocking font loading for snappier changes.

✏️ Tip: You can customize this high-level summary in your review settings.

- Add 7-day grace period for license validation to prevent theme resets
  on transient API failures (fixes #837)
- Cache license entitlement in localStorage for offline/error resilience
- Reduce license API polling from 30s to 1h with retry support
- Block premium theme switching during errors unless within grace period
- Show warning banner when license verification is unavailable
- Drastically improve theme switch performance by removing heavy CSS
  transitions (was applying to every DOM element with 400ms duration)
- Make font loading non-blocking
- Coalesce PWA theme updates via requestAnimationFrame
- Add mobile-web-app-capable meta tag
@coderabbitai

coderabbitai Bot commented Dec 22, 2025

Copy link
Copy Markdown
Contributor

Walkthrough

Adds a local license-entitlement store with a 7-day grace period and entitlement-aware gating for premium themes; propagates license error state through hooks; updates theme validation to preserve themes during transient failures; debounces PWA theme updates, shortens theme transitions, and adds a mobile-web-app meta tag.

Changes

Cohort / File(s) Summary
License Entitlement Module
web/src/lib/license-entitlement.ts
New module providing localStorage-backed entitlement persistence, GRACE_PERIOD_MS, LicenseEntitlement type, getLicenseEntitlement, setLicenseEntitlement, clearLicenseEntitlement, isWithinGracePeriod, and canSwitchToPremiumTheme with grace-period fallback logic.
License Hook Updates
web/src/hooks/useLicense.ts
Syncs license entitlement to storage on successful fetch, clears on delete success, and surfaces isError in returns; useHasPremiumAccess now returns { hasPremiumAccess, isLoading, isError }.
Theme Validation & Related Components
web/src/components/themes/ThemeValidator.tsx, web/src/components/themes/ThemeSelector.tsx
ThemeValidator refactored to use entitlement/grace logic, interval raised to 1 hour, returns null; ThemeSelector uses canSwitchToPremiumTheme, shows a distinct banner/toast when license verification fails.
Theme Selection Handlers
web/src/components/layout/MobileFooterNav.tsx, web/src/components/ui/ThemeToggle.tsx
Replace direct hasPremiumAccess checks with canSwitchToPremiumTheme({ hasPremiumAccess, isLoading, isError }); gate premium theme/variation switches on that result; surface distinct messages for verification errors vs missing entitlement; update hook dependencies.
Theme & PWA Utilities
web/src/utils/theme.ts, web/src/utils/pwaNativeTheme.ts
theme.ts: add ENABLE_THEME_TRANSITIONS (default false), reduce transition duration to 150ms, make font loading non-blocking, gate transitions. pwaNativeTheme.ts: add debounced scheduleUpdate to coalesce theme updates and prefer stored critical vars for background color.
Metadata
web/index.html
Added <meta name="mobile-web-app-capable" content="yes" /> in the head.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant UI as Theme UI (Selector/Toggle/Nav)
    participant Hook as useHasPremiumAccess
    participant API as License API
    participant Lib as license-entitlement
    participant Storage as localStorage

    User->>UI: Select premium theme
    UI->>Hook: request license state
    Hook->>API: fetch license
    alt API Success
        API-->>Hook: { hasPremiumAccess: true/false }
        Hook->>Lib: setLicenseEntitlement(hasPremiumAccess) (on success)
        Hook-->>UI: { hasPremiumAccess, isLoading:false, isError:false }
        UI->>Lib: canSwitchToPremiumTheme({hasPremiumAccess,false,false}) → allow/deny
        UI-->>User: apply theme or show premium prompt
    else API Error
        API-->>Hook: error
        Hook-->>UI: { hasPremiumAccess: undefined, isLoading:false, isError:true }
        UI->>Lib: getLicenseEntitlement()
        Lib->>Storage: read stored entitlement
        alt Within grace period
            Lib-->>UI: entitlement allows premium
            UI-->>User: apply theme (grace)
        else Grace expired / no entitlement
            Lib-->>UI: cannot switch
            UI-->>User: show "Unable to verify license" / deny
        end
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested labels

web, themes

Poem

🐰

I nibbled keys and stored the news,
Seven days' grace for theme-time views,
When servers nap, my cache holds true,
Debounced and brisk—your colors stay new,
A rabbit's hop keeps themes for you!

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.22% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main changes: preventing theme reset on license errors and improving performance through reduced transitions and optimized polling.
Linked Issues check ✅ Passed The PR implements all coding requirements from #837: grace period (7 days) for license validation, localStorage caching, increased polling from 30s to 1h, premium theme switching restrictions on errors, warning banner, removed heavy CSS transitions, non-blocking font loading, and coalesced PWA updates.
Out of Scope Changes check ✅ Passed All changes align with the linked issue #837. Minor addition of PWA meta tag in index.html is tangential but relates to PWA theme improvements mentioned in the PR objectives, falling within acceptable scope.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/premium-theme-validation

📜 Recent review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2270ad4 and e4a468d.

📒 Files selected for processing (2)
  • web/src/components/themes/ThemeSelector.tsx
  • web/src/components/ui/ThemeToggle.tsx
🧰 Additional context used
🧬 Code graph analysis (1)
web/src/components/ui/ThemeToggle.tsx (1)
web/src/lib/license-entitlement.ts (1)
  • canSwitchToPremiumTheme (63-85)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Run tests
🔇 Additional comments (7)
web/src/components/themes/ThemeSelector.tsx (3)

144-155: LGTM! Grace-period license checking integrated correctly.

The integration of canSwitchToPremiumTheme properly handles the three states (loading, error, success) and checks the 7-day grace period from cached entitlement during API failures. The isThemeLicensed simplification correctly gates premium themes based on the combined license state.


160-171: LGTM! Error messaging properly differentiates verification failures.

The early-return pattern correctly prioritizes license verification errors (isError) over generic missing-license messages, providing users with actionable context when the API is unavailable versus when they lack a valid license.


225-232: LGTM! Warning banner condition is correct.

The condition isError && !canSwitchPremium correctly identifies the scenario where license verification has failed AND the user is outside the grace period (or has no cached entitlement). This ensures the warning only appears when premium switching is actually blocked due to verification issues.

web/src/components/ui/ThemeToggle.tsx (4)

39-44: Previous stale modeKey concern is now resolved.

The modeKey is now derived from the reactive isDark state (Line 89), which is tracked in the useThemeChange hook (Lines 39, 44, 58) and updated whenever the THEME_CHANGE_EVENT fires. This ensures modeKey remains synchronized with the current theme mode, addressing the previous review's concern about stale values when dark mode changes while the dropdown is open.

Also applies to: 58-62, 89-89


82-117: LGTM! Preview color caching is well-implemented.

The previewColorsCache is created once with useMemo and provides stable identity for the getPreviewColors callback. The cache key ${modeKey}:${theme.id} correctly handles mode switches, and the callback's dependencies [modeKey, previewColorsCache] ensure it's recreated only when the mode changes. The color resolution logic properly handles var(--variation-color) by falling back to the first variation.


134-150: LGTM! License-aware theme selection with appropriate error messaging.

Both handleThemeSelect and handleVariationSelect correctly gate premium themes using canSwitchPremium and provide distinct error messages based on whether isError is true (license verification failure) or false (missing license). Closing the dropdown before applying the theme change (Line 145) improves perceived performance by providing immediate feedback.

Also applies to: 154-172


232-241: LGTM! Variation preview reveals on hover/focus.

The onMouseEnter and onFocus handlers correctly set activeThemeId only when a theme has variations, enabling on-demand display of variation color pills. This improves UX by revealing variations contextually without cluttering the dropdown.


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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (3)
web/src/components/ui/ThemeToggle.tsx (1)

188-202: Consider aligning isLocked with canSwitchPremium for visual consistency.

The isLocked check uses !hasPremiumAccess directly, but the handlers use canSwitchPremium. This could cause visual inconsistency:

  • During grace period with API error: theme appears locked (opacity-60) but selection would succeed
  • During loading with valid cached entitlement: same inconsistency

While not a functional bug (handlers correctly gate access), aligning the visual state improves UX.

Suggested fix
           .map((theme) => {
             const isPremium = isThemePremium(theme.id);
-            const isLocked = isPremium && !hasPremiumAccess;
+            const isLocked = isPremium && !canSwitchPremium;
             const colors = getThemeColors(theme);
web/src/components/themes/ThemeValidator.tsx (2)

34-34: Consider standardizing console output.

The code uses both console.warn (line 34) and console.log (lines 44, 52, 88, 106) inconsistently. For production code, consider:

  • Standardizing on console.warn for error-related conditions
  • Using a logging library for better control over log levels
  • Or removing debug logs entirely for production builds

Also applies to: 44-44, 52-52, 88-88, 106-106


56-56: Minor optimization: reduce localStorage reads.

The code reads localStorage.getItem("color-theme") multiple times across different code paths. While not a significant performance concern, you could read it once at the start of each effect and reuse the value for cleaner code.

Example refactor for the first effect
   useEffect(() => {
     // Don't do anything while loading - let the stored theme persist
     if (isLoading) return
+
+    const storedThemeId = localStorage.getItem("color-theme")

     // If there's an error fetching license data
     if (isError) {
       // ... rest of error handling uses storedThemeId variable
     }

     // ... success case also uses storedThemeId variable
   }, [data, isLoading, isError])

Also applies to: 86-86, 103-103

📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0684d75 and c96e236.

📒 Files selected for processing (9)
  • web/index.html
  • web/src/components/layout/MobileFooterNav.tsx
  • web/src/components/themes/ThemeSelector.tsx
  • web/src/components/themes/ThemeValidator.tsx
  • web/src/components/ui/ThemeToggle.tsx
  • web/src/hooks/useLicense.ts
  • web/src/lib/license-entitlement.ts
  • web/src/utils/pwaNativeTheme.ts
  • web/src/utils/theme.ts
🧰 Additional context used
🧠 Learnings (1)
📓 Common learnings
Learnt from: s0up4200
Repo: autobrr/qui PR: 617
File: internal/services/license/license_service.go:23-24
Timestamp: 2025-11-20T17:10:01.827Z
Learning: In the autobrr/qui repository, the `UpdateLicenseStatus` method in `internal/database/license_repo.go` updates both the `status` field and the `last_validated` timestamp to `time.Now()` whenever it's called, ensuring the offline grace period window advances correctly on successful license validations.
🧬 Code graph analysis (6)
web/src/components/layout/MobileFooterNav.tsx (4)
web/src/hooks/useLicense.ts (1)
  • useHasPremiumAccess (107-115)
web/src/lib/license-entitlement.ts (1)
  • canSwitchToPremiumTheme (93-118)
web/src/utils/theme.ts (2)
  • setTheme (252-300)
  • themes (449-449)
web/src/config/themes.ts (3)
  • themes (22-22)
  • themes (38-38)
  • isThemePremium (33-36)
web/src/hooks/useLicense.ts (2)
web/src/lib/api.ts (1)
  • api (1716-1716)
web/src/lib/license-entitlement.ts (2)
  • setLicenseEntitlement (52-62)
  • clearLicenseEntitlement (67-73)
web/src/utils/theme.ts (1)
web/src/utils/fontLoader.ts (1)
  • loadThemeFonts (56-80)
web/src/components/themes/ThemeValidator.tsx (3)
web/src/lib/license-entitlement.ts (2)
  • getLicenseEntitlement (28-47)
  • isWithinGracePeriod (78-83)
web/src/utils/theme.ts (3)
  • themes (449-449)
  • setValidatedThemes (207-210)
  • setTheme (252-300)
web/src/config/themes.ts (5)
  • themes (22-22)
  • themes (38-38)
  • isThemePremium (33-36)
  • getThemeById (25-27)
  • getDefaultTheme (29-31)
web/src/components/ui/ThemeToggle.tsx (3)
web/src/hooks/useLicense.ts (1)
  • useHasPremiumAccess (107-115)
web/src/lib/license-entitlement.ts (1)
  • canSwitchToPremiumTheme (93-118)
web/src/config/themes.ts (1)
  • isThemePremium (33-36)
web/src/components/themes/ThemeSelector.tsx (3)
web/src/hooks/useLicense.ts (1)
  • useHasPremiumAccess (107-115)
web/src/lib/license-entitlement.ts (1)
  • canSwitchToPremiumTheme (93-118)
web/src/config/themes.ts (1)
  • isThemePremium (33-36)
🔇 Additional comments (24)
web/index.html (1)

9-9: LGTM!

This meta tag properly enables full-screen PWA mode on Android/Chrome, complementing the existing apple-mobile-web-app-capable for iOS.

web/src/utils/theme.ts (2)

17-26: LGTM!

Reducing the transition to only the root background color is a solid performance optimization. Child elements receiving CSS variable changes instantly avoids expensive repaints on many DOM elements.


77-82: LGTM!

Non-blocking font loading improves perceived theme-switch performance. Users may briefly see fallback fonts (FOUT), but this is an acceptable trade-off for faster theme application.

web/src/utils/pwaNativeTheme.ts (1)

128-161: LGTM!

Good use of requestAnimationFrame to coalesce rapid theme mutations into a single update per frame. This prevents redundant meta tag updates when theme application triggers multiple attribute changes in quick succession.

web/src/hooks/useLicense.ts (3)

13-27: LGTM!

The changes correctly:

  1. Persist entitlement on successful response for grace-period support
  2. Reduce polling from 30s to 1 hour (reducing CPU usage)
  3. Add retry support for transient failures
  4. Enable refetch on reconnect for better resilience

76-82: LGTM!

Clearing the cached entitlement on license deletion is essential to prevent the grace period from incorrectly allowing premium access after intentional license removal.


107-115: LGTM!

Exposing isError enables UI components to distinguish between "no license" and "verification failed", allowing for appropriate user messaging.

web/src/components/layout/MobileFooterNav.tsx (3)

99-100: LGTM!

Good use of the centralized canSwitchToPremiumTheme utility for consistent entitlement-aware premium access checks across UI components.


138-154: LGTM!

The conditional error messaging provides clear user feedback: "Unable to verify license" for API failures vs. "premium theme" prompt for missing licenses. Dependency array correctly includes canSwitchPremium and isError.


156-174: LGTM!

Variation selection correctly mirrors the theme selection logic, ensuring consistent entitlement checks and error messaging.

web/src/lib/license-entitlement.ts (5)

15-23: LGTM!

Good design choices:

  • Versioned storage key (v1) enables future schema migrations
  • Grace period matches backend's offlineGracePeriod (7 days) as noted in the learnings

28-47: LGTM!

Shape validation ensures corrupted or tampered localStorage data doesn't cause runtime errors. Silent error handling is appropriate here.


52-73: LGTM!

Both functions handle localStorage errors gracefully, which is important for private browsing modes where localStorage may be unavailable.


78-83: LGTM!

Simple and correct grace period check using Unix timestamp comparison.


93-140: LGTM!

The two-tier approach is well-designed:

  • canSwitchToPremiumTheme: More restrictive, gates new premium theme selections
  • shouldKeepCurrentPremiumTheme: More lenient, prevents jarring theme resets during transient failures

Both correctly leverage the grace period for resilience during API outages.

web/src/components/themes/ThemeSelector.tsx (4)

142-156: LGTM!

The canSwitchPremium computation and isThemeLicensed helper centralize the entitlement-aware access check, consistent with other components.


161-193: LGTM!

Error messaging is consistent across all theme-related components, providing clear feedback for both license verification failures and missing licenses.


230-238: LGTM!

The warning banner appropriately displays only when license verification fails and the user is outside the grace period. The yellow styling correctly indicates a temporary issue rather than a permanent error.


195-216: LGTM!

Loading state provides appropriate visual feedback while license data is being fetched.

web/src/components/ui/ThemeToggle.tsx (2)

60-70: LGTM!

Consistent setup with other components for entitlement-aware premium theme switching.


81-124: LGTM!

Theme and variation selection handlers correctly use canSwitchPremium with context-aware error messaging. Dependency arrays are properly updated.

web/src/components/themes/ThemeValidator.tsx (3)

33-68: Excellent grace period implementation.

The grace period logic correctly handles transient license validation failures:

  • Preserves premium theme access within the 7-day grace window
  • Restricts to free themes outside the grace period without forcing an immediate reset
  • Keeps the current premium theme accessible even outside grace to avoid jarring UX changes
  • Defers actual theme resets until a confirmed no-access response

This implementation properly addresses issue #837 by preventing theme resets on transient API errors while maintaining security.


70-90: Correct handling of confirmed license state.

The downgrade logic appropriately distinguishes between transient errors and confirmed loss of entitlement. The explicit check data?.hasPremiumAccess === false (rather than a truthy check) correctly ensures theme resets only occur when premium access is definitively denied, not during error states.


94-129: Periodic validation logic is sound.

The validation interval correctly implements the 1-hour polling frequency (reduced from 30 seconds per PR objectives). The logic appropriately:

  • Only validates when confirmed data is available (skips during error states)
  • Checks for premium theme usage with confirmed no-access
  • Handles cross-tab storage changes
  • Properly cleans up intervals and event listeners

The dependency array [data, isLoading, isError] is correct as the closures need to capture the latest state.

Comment thread web/src/components/themes/ThemeValidator.tsx Outdated
Comment thread web/src/components/themes/ThemeValidator.tsx Outdated
@blacksmith-sh

This comment has been minimized.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (2)
web/src/components/themes/ThemeValidator.tsx (2)

23-23: Explicit return type added per PR objectives.

The function signature now includes the explicit : null return type as requested in the PR objectives and previous review comments.


78-111: Periodic validation properly guards against error states.

The validation logic correctly:

  • Skips validation during loading and error states (lines 79-83)
  • Uses the named constant THEME_VALIDATION_INTERVAL_MS (line 94), addressing a previous review comment
  • Includes isError in the dependency array (line 111) to properly react to error state changes

This ensures periodic validation only runs when license state is confirmed, reducing CPU usage during transient failures as intended.

🧹 Nitpick comments (1)
web/src/components/themes/ThemeSelector.tsx (1)

160-191: Consider extracting duplicate toast logic.

The error handling in handleThemeSelect and handleVariationSelect is identical. A small helper would reduce duplication:

Proposed refactor
+  const showLicenseError = () => {
+    if (isError) {
+      toast.error("Unable to verify license", {
+        description: "License check failed. Premium theme switching is temporarily unavailable.",
+      })
+    } else {
+      toast.error("This theme requires a premium license", {
+        description: "Please purchase a license to access premium themes",
+      })
+    }
+  }
+
   const handleThemeSelect = (themeId: string) => {
     if (isThemeLicensed(themeId)) {
       setTheme(themeId)
     } else {
-      if (isError) {
-        toast.error("Unable to verify license", {
-          description: "License check failed. Premium theme switching is temporarily unavailable.",
-        })
-      } else {
-        toast.error("This theme requires a premium license", {
-          description: "Please purchase a license to access premium themes",
-        })
-      }
+      showLicenseError()
     }
   }
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c96e236 and 2270ad4.

📒 Files selected for processing (7)
  • web/src/components/themes/ThemeSelector.tsx
  • web/src/components/themes/ThemeValidator.tsx
  • web/src/components/ui/ThemeToggle.tsx
  • web/src/hooks/useLicense.ts
  • web/src/lib/license-entitlement.ts
  • web/src/utils/pwaNativeTheme.ts
  • web/src/utils/theme.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • web/src/utils/pwaNativeTheme.ts
🧰 Additional context used
🧬 Code graph analysis (4)
web/src/components/ui/ThemeToggle.tsx (2)
web/src/hooks/useLicense.ts (1)
  • useHasPremiumAccess (110-118)
web/src/lib/license-entitlement.ts (1)
  • canSwitchToPremiumTheme (63-85)
web/src/hooks/useLicense.ts (1)
web/src/lib/license-entitlement.ts (2)
  • setLicenseEntitlement (35-45)
  • clearLicenseEntitlement (47-53)
web/src/utils/theme.ts (1)
web/src/utils/fontLoader.ts (1)
  • loadThemeFonts (56-80)
web/src/components/themes/ThemeSelector.tsx (3)
web/src/hooks/useLicense.ts (1)
  • useHasPremiumAccess (110-118)
web/src/lib/license-entitlement.ts (1)
  • canSwitchToPremiumTheme (63-85)
web/src/config/themes.ts (1)
  • isThemePremium (33-36)
🔇 Additional comments (19)
web/src/hooks/useLicense.ts (3)

14-31: LGTM! Entitlement persistence and polling changes align with PR objectives.

The changes correctly:

  • Store entitlement via useEffect when query data is available
  • Reduce polling from 30s to 1 hour (staleTime and refetchInterval)
  • Add retry support with retry: 2
  • Enable reconnection-triggered refetch

80-85: LGTM! Correctly clears cached entitlement on license deletion.

The entitlement is cleared before invalidating queries, ensuring the UI reflects the correct state immediately.


110-118: LGTM! Exposing isError enables proper error handling in UI components.

This allows components like ThemeSelector and ThemeToggle to differentiate between "no license" and "verification failed" states.

web/src/utils/theme.ts (3)

17-19: Performance improvements look good.

Transitions are effectively disabled with ENABLE_THEME_TRANSITIONS = false, and the duration is reduced to 150ms for when transitions are re-enabled. This aligns with the PR goal of making theme switching feel instant.


81-83: Non-blocking font loading improves theme switch performance.

Fonts load in the background while the theme applies immediately. Users may briefly see fallback fonts, but the overall UX is faster.


21-27: Lightweight transition CSS is a good simplification.

Only transitioning the root background color while child elements inherit instantly via CSS variables is an effective performance optimization.

web/src/components/themes/ThemeSelector.tsx (2)

144-154: LGTM! Entitlement-aware gating is correctly implemented.

The canSwitchToPremiumTheme function centralizes the grace-period logic, and isThemeLicensed properly gates premium theme access.


228-235: LGTM! Warning banner provides good user feedback.

The banner correctly appears only when license verification fails AND the grace period has expired, clearly communicating why premium themes are temporarily unavailable.

web/src/components/ui/ThemeToggle.tsx (4)

71-78: LGTM! Theme sorting is correctly memoized.

Since themes is a static import, the empty dependency array is appropriate.


80-115: Color caching implementation is sound.

The cache correctly keys by both mode and theme ID. The cache will work correctly once the modeKey staleness issue is addressed.


130-170: LGTM! Error handling aligns with ThemeSelector.

The premium gating and error-aware toast messages are consistent across components. The dropdown close timing differs between theme and variation selection, which seems intentional given variation involves two sequential operations.


219-244: LGTM! Hover/focus handlers improve discoverability of theme variations.

The onFocus handler ensures keyboard users can also discover variations, which is good for accessibility.

web/src/lib/license-entitlement.ts (4)

1-14: LGTM! Well-structured entitlement persistence module.

  • Versioned storage key (v1) enables future migrations
  • 7-day grace period matches backend offlineGracePeriod
  • Clear interface definition

16-33: LGTM! Defensive parsing with proper validation.

The runtime validation of parsed JSON structure prevents type assertion issues from propagating.


55-61: LGTM! Grace period check with future timestamp guard.

The check for lastSuccessfulValidationAt > now is good defensive coding that prevents issues from clock manipulation or data corruption.


63-85: LGTM! Three-path logic correctly handles all API states.

The function properly:

  1. Uses cached entitlement during loading (within grace period)
  2. Returns live API result on success
  3. Falls back to cached entitlement on error (within grace period)

This aligns with the PR objective to prevent premium theme resets on transient API failures while still respecting confirmed loss of entitlement.

web/src/components/themes/ThemeValidator.tsx (3)

6-12: LGTM! Imports and constant declaration address PR objectives.

The new imports (getThemeById, getLicenseEntitlement, isWithinGracePeriod) support the grace period implementation, and the named constant THEME_VALIDATION_INTERVAL_MS addresses a previous review comment about extracting magic numbers.


71-74: Excellent: only resets on confirmed loss of entitlement.

The explicit data?.hasPremiumAccess === false check (line 72) correctly distinguishes between:

  • Transient errors (data is undefined) → no reset
  • Confirmed loss of access (data.hasPremiumAccess is explicitly false) → reset to default

This precisely addresses the PR objective of preserving themes during API failures while maintaining security on confirmed entitlement loss.


33-56: Grace period error handling correctly implements PR objectives with proper null safety.

The error handling path properly addresses issue #837 by:

  • Preserving the current theme when within the 7-day grace period (lines 40-44)
  • Blocking premium theme switching when outside grace, while keeping the currently applied premium theme (lines 46-55)
  • Avoiding forced resets on transient API failures

The isWithinGracePeriod function safely handles null input from getLicenseEntitlement() with an explicit guard clause (if (!entitlement) return false), and its type signature correctly accepts LicenseEntitlement | null.

Comment thread web/src/components/ui/ThemeToggle.tsx Outdated
@s0up4200
s0up4200 merged commit 40982bc into main Dec 22, 2025
11 checks passed
@s0up4200
s0up4200 deleted the fix/premium-theme-validation branch December 22, 2025 21:26
alexlebens pushed a commit to alexlebens/infrastructure that referenced this pull request Jan 4, 2026
This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [ghcr.io/autobrr/qui](https://github.com/autobrr/qui) | minor | `v1.11.0` → `v1.12.0` |

---

### Release Notes

<details>
<summary>autobrr/qui (ghcr.io/autobrr/qui)</summary>

### [`v1.12.0`](https://github.com/autobrr/qui/releases/tag/v1.12.0)

[Compare Source](autobrr/qui@v1.11.0...v1.12.0)

#### Changelog

##### New Features

- [`202e950`](autobrr/qui@202e950): feat(automations): Add `free_space` condition ([#&#8203;1061](autobrr/qui#1061)) ([@&#8203;Barcode-eng](https://github.com/Barcode-eng))
- [`3b106d6`](autobrr/qui@3b106d6): feat(automations): make conditions optional for non-delete rules and add drag reorder ([#&#8203;939](autobrr/qui#939)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`0684d75`](autobrr/qui@0684d75): feat(config): Add QUI\_\_OIDC\_CLIENT\_SECRET\_FILE env var ([#&#8203;841](autobrr/qui#841)) ([@&#8203;PedDavid](https://github.com/PedDavid))
- [`dac0173`](autobrr/qui@dac0173): feat(config): allow disabling tracker icon fetching ([#&#8203;823](autobrr/qui#823)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`dc10bad`](autobrr/qui@dc10bad): feat(crossseed): add cancel run and opt-in errored torrent recovery ([#&#8203;918](autobrr/qui#918)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`cd1fcc9`](autobrr/qui@cd1fcc9): feat(crossseed): add custom category option for cross-seeds ([#&#8203;907](autobrr/qui#907)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`d189fe9`](autobrr/qui@d189fe9): feat(crossseed): add indexerName to webhook apply + fix category mode defaults ([#&#8203;916](autobrr/qui#916)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`03a147e`](autobrr/qui@03a147e): feat(crossseed): add option to skip recheck-required matches ([#&#8203;825](autobrr/qui#825)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`edae00a`](autobrr/qui@edae00a): feat(crossseed): add optional hardlink mode for cross-seeding ([#&#8203;849](autobrr/qui#849)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`0938436`](autobrr/qui@0938436): feat(crossseed): add source aliasing for WEB/WEB-DL/WEBRip precheck matching ([#&#8203;874](autobrr/qui#874)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`65f6129`](autobrr/qui@65f6129): feat(crossseed): show failure reasons, prune runs, and add cache cleanup ([#&#8203;923](autobrr/qui#923)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`e10fba8`](autobrr/qui@e10fba8): feat(details): torrent details panel improvements ([#&#8203;884](autobrr/qui#884)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`6921140`](autobrr/qui@6921140): feat(docs): add Docusaurus documentation site ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`6a5a66c`](autobrr/qui@6a5a66c): feat(docs): add Icon and webUI variables to the Unraid install guide ([#&#8203;942](autobrr/qui#942)) ([@&#8203;BaukeZwart](https://github.com/BaukeZwart))
- [`281fce7`](autobrr/qui@281fce7): feat(docs): add local search plugin ([#&#8203;1076](autobrr/qui#1076)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`566de08`](autobrr/qui@566de08): feat(docs): add qui logo, update readme, remove v4 flag ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`b83ac5a`](autobrr/qui@b83ac5a): feat(docs): apply minimal.css theme to Docusaurus ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`fe6a6df`](autobrr/qui@fe6a6df): feat(docs): improve documentation pages and add support page ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`62a7ad5`](autobrr/qui@62a7ad5): feat(docs): use qui favicon ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`5d124c0`](autobrr/qui@5d124c0): feat(orphan): add auto cleanup mode ([#&#8203;897](autobrr/qui#897)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`3172ad9`](autobrr/qui@3172ad9): feat(settings): add log settings + live log stream ([#&#8203;876](autobrr/qui#876)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`3c1b34b`](autobrr/qui@3c1b34b): feat(torrents): add "torrent introuvable" to unregistered status ([#&#8203;836](autobrr/qui#836)) ([@&#8203;kephasdev](https://github.com/kephasdev))
- [`afe4d39`](autobrr/qui@afe4d39): feat(torrents): add tracker URL editing for single torrents ([#&#8203;848](autobrr/qui#848)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`76dedd7`](autobrr/qui@76dedd7): feat(torrents): update GeneralTabHorizontal to display limits and improve layout ([#&#8203;1078](autobrr/qui#1078)) ([@&#8203;martylukyy](https://github.com/martylukyy))
- [`6831c24`](autobrr/qui@6831c24): feat(ui): unify payment options into single dialog ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`4dcdf7f`](autobrr/qui@4dcdf7f): feat(web): add local file access indicator to instance cards ([#&#8203;911](autobrr/qui#911)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`a560e5e`](autobrr/qui@a560e5e): feat(web): compact torrent details panel ([#&#8203;833](autobrr/qui#833)) ([@&#8203;martylukyy](https://github.com/martylukyy))
- [`557e7bd`](autobrr/qui@557e7bd): feat: add issue/discussion template ([#&#8203;945](autobrr/qui#945)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`8b93719`](autobrr/qui@8b93719): feat: add workflow automation system with category actions, orphan scanner, and hardlink detection ([#&#8203;818](autobrr/qui#818)) ([@&#8203;s0up4200](https://github.com/s0up4200))

##### Bug Fixes

- [`b85ad6b`](autobrr/qui@b85ad6b): fix(automations): allow delete rules to match incomplete torrents ([#&#8203;926](autobrr/qui#926)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`ae06200`](autobrr/qui@ae06200): fix(automations): make tags field condition operators tag-aware ([#&#8203;908](autobrr/qui#908)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`ace0101`](autobrr/qui@ace0101): fix(crossseed): detect folder mismatch for bare file to folder cross-seeds ([#&#8203;846](autobrr/qui#846)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`1cc1243`](autobrr/qui@1cc1243): fix(crossseed): enforce resolution and language matching with sensible defaults ([#&#8203;855](autobrr/qui#855)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`cefb9cd`](autobrr/qui@cefb9cd): fix(crossseed): execute external program reliably after injection ([#&#8203;1083](autobrr/qui#1083)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`867e2da`](autobrr/qui@867e2da): fix(crossseed): improve matching with size validation and relaxed audio checks ([#&#8203;845](autobrr/qui#845)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`4b5079b`](autobrr/qui@4b5079b): fix(crossseed): persist custom category settings in PATCH handler ([#&#8203;913](autobrr/qui#913)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`cfbbc1f`](autobrr/qui@cfbbc1f): fix(crossseed): prevent season packs matching episodes ([#&#8203;854](autobrr/qui#854)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`c7c1706`](autobrr/qui@c7c1706): fix(crossseed): reconcile interrupted runs on startup ([#&#8203;1084](autobrr/qui#1084)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`7d633bd`](autobrr/qui@7d633bd): fix(crossseed): use ContentPath for manually-managed single-file torrents ([#&#8203;832](autobrr/qui#832)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`d5db761`](autobrr/qui@d5db761): fix(database): include arr\_instances in string pool cleanup + remove auto-recovery ([#&#8203;898](autobrr/qui#898)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`c73ec6f`](autobrr/qui@c73ec6f): fix(database): prevent race between stmt cache access and db close ([#&#8203;840](autobrr/qui#840)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`a40b872`](autobrr/qui@a40b872): fix(db): drop legacy hardlink columns from cross\_seed\_settings ([#&#8203;912](autobrr/qui#912)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`e400af3`](autobrr/qui@e400af3): fix(db): recover wedged SQLite writer + stop cross-seed tight loop ([#&#8203;890](autobrr/qui#890)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`90e15b4`](autobrr/qui@90e15b4): fix(deps): update rls to recognize IP as iPlayer ([#&#8203;922](autobrr/qui#922)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`8e81b9f`](autobrr/qui@8e81b9f): fix(proxy): honor TLSSkipVerify for proxied requests ([#&#8203;1051](autobrr/qui#1051)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`eb2bee0`](autobrr/qui@eb2bee0): fix(security): redact sensitive URL parameters in logs ([#&#8203;853](autobrr/qui#853)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`40982bc`](autobrr/qui@40982bc): fix(themes): prevent reset on license errors, improve switch performance ([#&#8203;844](autobrr/qui#844)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`a8a32f7`](autobrr/qui@a8a32f7): fix(ui): incomplete torrents aren't "Completed: 1969-12-31" ([#&#8203;851](autobrr/qui#851)) ([@&#8203;finevan](https://github.com/finevan))
- [`5908bba`](autobrr/qui@5908bba): fix(ui): preserve category collapse state when toggling incognito mode ([#&#8203;834](autobrr/qui#834)) ([@&#8203;jabloink](https://github.com/jabloink))
- [`25c847e`](autobrr/qui@25c847e): fix(ui): torrents with no creation metadata don't display 1969 ([#&#8203;873](autobrr/qui#873)) ([@&#8203;finevan](https://github.com/finevan))
- [`6403b6a`](autobrr/qui@6403b6a): fix(web): column filter status now matches all states in category ([#&#8203;880](autobrr/qui#880)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`eafc4e7`](autobrr/qui@eafc4e7): fix(web): make delete cross-seed check rely on content\_path matches ([#&#8203;1080](autobrr/qui#1080)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`d57c749`](autobrr/qui@d57c749): fix(web): only show selection checkbox on normal view ([#&#8203;830](autobrr/qui#830)) ([@&#8203;jabloink](https://github.com/jabloink))
- [`60338f6`](autobrr/qui@60338f6): fix(web): optimize TorrentDetailsPanel for mobile view and make tabs scrollable horizontally ([#&#8203;1066](autobrr/qui#1066)) ([@&#8203;martylukyy](https://github.com/martylukyy))
- [`aedab87`](autobrr/qui@aedab87): fix(web): speed limit input reformatting during typing ([#&#8203;881](autobrr/qui#881)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`df7f3e0`](autobrr/qui@df7f3e0): fix(web): truncate file progress percentage instead of rounding ([#&#8203;919](autobrr/qui#919)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`2fadd01`](autobrr/qui@2fadd01): fix(web): update eslint config for flat config compatibility ([#&#8203;879](autobrr/qui#879)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`721cedd`](autobrr/qui@721cedd): fix(web): use fixed heights for mobile torrent cards ([#&#8203;812](autobrr/qui#812)) ([@&#8203;jabloink](https://github.com/jabloink))
- [`a7db605`](autobrr/qui@a7db605): fix: remove pnpm-workspace.yaml breaking CI ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`c0ddc0a`](autobrr/qui@c0ddc0a): fix: use prefix matching for allowed bash commands ([@&#8203;s0up4200](https://github.com/s0up4200))

##### Other Changes

- [`fff52ce`](autobrr/qui@fff52ce): chore(ci): disable reviewer ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`7ef2a38`](autobrr/qui@7ef2a38): chore(ci): fix automated triage and deduplication workflows ([#&#8203;1057](autobrr/qui#1057)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`d84910b`](autobrr/qui@d84910b): chore(docs): move Tailwind to documentation workspace only ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`37ebe05`](autobrr/qui@37ebe05): chore(docs): move netlify.toml to documentation directory ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`e25de38`](autobrr/qui@e25de38): chore(docs): remove disclaimer ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`c59b809`](autobrr/qui@c59b809): chore(docs): update support sections ([#&#8203;1063](autobrr/qui#1063)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`b723523`](autobrr/qui@b723523): chore(tests): remove dead tests and optimize slow test cases ([#&#8203;842](autobrr/qui#842)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`662a1c6`](autobrr/qui@662a1c6): chore(workflows): update runners from 4vcpu to 2vcpu for all jobs ([#&#8203;859](autobrr/qui#859)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`46f2a1c`](autobrr/qui@46f2a1c): chore: clean up repo root by moving Docker, scripts, and community docs ([#&#8203;1054](autobrr/qui#1054)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`2f27c0d`](autobrr/qui@2f27c0d): chore: remove old issue templates ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`04f361a`](autobrr/qui@04f361a): ci(triage): add labeling for feature-requests-ideas discussions ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`f249c69`](autobrr/qui@f249c69): ci(triage): remove needs-triage label after applying labels ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`bdda1de`](autobrr/qui@bdda1de): ci(workflows): add self-dispatch workaround for discussion events ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`a9732a2`](autobrr/qui@a9732a2): ci(workflows): increase max-turns to 25 for Claude workflows ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`d7d830d`](autobrr/qui@d7d830d): docs(README): add Buy Me a Coffee link ([#&#8203;863](autobrr/qui#863)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`266d92e`](autobrr/qui@266d92e): docs(readme): Clarify ignore pattern ([#&#8203;878](autobrr/qui#878)) ([@&#8203;quorn23](https://github.com/quorn23))
- [`9586084`](autobrr/qui@9586084): docs(readme): add banner linking to stable docs ([#&#8203;925](autobrr/qui#925)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`e36a621`](autobrr/qui@e36a621): docs(readme): use markdown link for Polar URL ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`9394676`](autobrr/qui@9394676): docs: add frontmatter titles and descriptions, remove marketing language ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`ba9d45e`](autobrr/qui@ba9d45e): docs: add local filesystem access snippet and swizzle Details component ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`4329edd`](autobrr/qui@4329edd): docs: disclaimer about unreleased features ([#&#8203;943](autobrr/qui#943)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`735d065`](autobrr/qui@735d065): docs: improve external programs, orphan scan, reverse proxy, tracker icons documentation ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`78faef2`](autobrr/qui@78faef2): docs: remove premature tip and fix stat command ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`eaad3bf`](autobrr/qui@eaad3bf): docs: update social card image in Docusaurus configuration ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`02a68e5`](autobrr/qui@02a68e5): refactor(crossseed): hardcode ignore patterns for file matching ([#&#8203;915](autobrr/qui#915)) ([@&#8203;s0up4200](https://github.com/s0up4200))

**Full Changelog**: <autobrr/qui@v1.11.0...v1.12.0>

#### Docker images

- `docker pull ghcr.io/autobrr/qui:v1.12.0`
- `docker pull ghcr.io/autobrr/qui:latest`

#### What to do next?

- Join our [Discord server](https://discord.autobrr.com/qui)

Thank you for using qui!

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0Mi42OS4yIiwidXBkYXRlZEluVmVyIjoiNDIuNjkuMiIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiaW1hZ2UiXX0=-->

Reviewed-on: https://gitea.alexlebens.dev/alexlebens/infrastructure/pulls/3060
Co-authored-by: Renovate Bot <renovate-bot@alexlebens.net>
Co-committed-by: Renovate Bot <renovate-bot@alexlebens.net>
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