Skip to content

feat: add layered theme system for plugins#122

Merged
tomymaritano merged 8 commits into
developfrom
feature/theme-system
Mar 11, 2026
Merged

feat: add layered theme system for plugins#122
tomymaritano merged 8 commits into
developfrom
feature/theme-system

Conversation

@tomymaritano

@tomymaritano tomymaritano commented Mar 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Token whitelist + validation: 24 core CSS tokens (--bg-*, --text-*, --border-*, etc.) plus extension scopes (--syntax-*, --preview-*, --ui-*) that plugins can override
  • ThemeRegistry store: Vanilla Zustand store for registering, validating, and activating plugin themes with auto-cleanup on unregister
  • useThemeOverrides hook: React hook applying active theme tokens as CSS custom properties on :root with data-theme attribute
  • nativeTheme IPC sync: Electron's nativeTheme.themeSource synced via IPC between main/renderer, replacing unreliable prefers-color-scheme media queries
  • Settings UI: Plugin theme selector in Appearance section (conditional on registered themes)
  • Startup restore: Active theme ID persisted in settings schema and restored on app startup
  • Full test coverage: 14 tests for token validation + registry store behavior

Architecture

Layered system: Base CSS tokens → Theme overrides (ThemeRegistry) → Accent color → Plugin CSS vars (cssVariableStore). Each layer has a clear purpose and doesn't interfere with others.

Test plan

  • pnpm typecheck — 18/18 packages pass
  • pnpm test — 167 plugin-api tests pass (including 14 new theme tests)
  • Token validation rejects invalid tokens, accepts core + extension scoped tokens
  • Registry auto-deactivates removed themes
  • nativeTheme IPC handler syncs theme source to Electron

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Added plugin theme support: plugins can now register custom themes that appear as selectable options in Appearance settings.
    • Implemented system theme synchronization: automatically detects and applies system dark/light mode preferences.
    • Enhanced Appearance settings with a new Plugin Theme selector for choosing between default and plugin-provided themes.
    • Themes now persist across app restarts.
  • Chores

    • Minor code formatting updates.

tomymaritano and others added 6 commits March 11, 2026 11:53
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…try store

Introduce ThemeDefinition type, CORE_THEME_TOKENS whitelist, and
validation helpers that reject unknown CSS tokens. Add ThemeRegistry
Zustand vanilla store for registering/unregistering themes with
active theme management.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…t method

Wire theme system into the app: useThemeOverrides applies active theme
tokens to document root, and registerTheme lets plugins register full
themes through the PluginContext with proper lifecycle cleanup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Wire up Electron's nativeTheme API so the renderer can set the theme
source (dark/light/system) and receive system theme change notifications
via IPC instead of relying solely on window.matchMedia.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add activeThemeId to AppearanceSettings schema, plugin theme selector
in AppearanceSection (shown only when plugin themes are registered),
and startup restoration of the saved theme in App.tsx.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Cover isValidThemeToken, validateThemeTokens, and full themeRegistryStore
lifecycle (register, unregister, unregisterAll, setActive, getActiveTheme).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Mar 11, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 08ab5364-dfab-496f-bb39-110f1ca0b48e

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR introduces a comprehensive theme system enabling plugins to register custom themes and manage them through a centralized registry. It integrates Electron's native theme support with IPC-based synchronization, adds theme persistence to appearance settings, and updates the renderer UI to support theme selection alongside token validation.

Changes

Cohort / File(s) Summary
Theme System Core
packages/plugin-api/src/theme/themeTypes.ts, packages/plugin-api/src/theme/themeRegistryStore.ts, packages/plugin-api/src/theme/useThemeOverrides.ts
Introduces CORE_THEME_TOKENS and THEME_EXTENSION_SCOPES whitelists, a ThemeDefinition interface, token validation utilities (isValidThemeToken, validateThemeTokens), a Zustand-based theme registry store with register/unregister/setActive/getActiveTheme methods, and a useThemeOverrides hook that applies active theme tokens to the document root.
Electron IPC Integration
apps/desktop/src/main/index.ts, apps/desktop/src/preload/index.ts
Adds Electron nativeTheme integration with IPC handlers for theme:set-source (to set nativeTheme.themeSource) and theme:system-changed broadcasts; exposes theme API in preload with setSource() and onSystemChanged() callback registration (includes duplicated theme synchronization logic in initialization).
Renderer Theme Synchronization
apps/desktop/src/renderer/hooks/useAppearanceSettings.ts
Replaces OS-level matchMedia listeners with IPC-based system theme change handling via window.readied.theme.onSystemChanged; refactors applyAppearance to accept optional isDark parameter for system theme resolution; synchronizes theme source changes to main process via IPC.
Settings & Schema
apps/desktop/src/renderer/stores/settings/schema.ts
Adds activeThemeId: string
Renderer App Integration
apps/desktop/src/renderer/App.tsx
Calls useThemeOverrides() hook and adds startup effect to restore saved active theme from settings if a registered theme exists.
Appearance UI
apps/desktop/src/renderer/pages/settings/sections/AppearanceSection.tsx
Integrates themeRegistryStore via useSyncExternalStore to populate a new Plugin Theme selector; adds handlePluginThemeChange handler to apply selected themes via themeRegistryStore.setActive().
Plugin Registry Integration
packages/plugin-api/src/lifecycle/PluginRegistry.ts, packages/plugin-api/src/types.ts
Adds registerTheme(theme) method to PluginContext; wires theme registration into plugin activation and cleanup during deactivation/error recovery via themeRegistryStore.
Plugin API Exports
packages/plugin-api/src/index.ts
Exports new theme system APIs: useThemeOverrides hook, themeRegistryStore, ThemeDefinition type, and token validation utilities (isValidThemeToken, validateThemeTokens, CORE_THEME_TOKENS, THEME_EXTENSION_SCOPES).
Test Coverage
packages/plugin-api/tests/themeRegistryStore.test.ts, packages/plugin-api/tests/themeTypes.test.ts
Adds comprehensive tests for theme registry operations (registration, unregistration, active theme handling, token validation) and token validation logic (core tokens, extension scopes, invalid token filtering).
Sync Service Refactoring
packages/api/src/routes/sync.ts, packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts
Consolidates notebook push transaction logic with unified version tracking and conflict detection; minor type assertion refinements in tag repository operations.
Supporting Files
apps/desktop/src/main/services/syncService.ts
Minor formatting adjustments in pullTags and pushTags output structure; no functional changes to control flow or logic.
Implementation Plan
docs/plans/2026-03-11-theme-system-implementation.md
Comprehensive documentation describing the complete theme system architecture, integration points, validation strategy, and lifecycle management.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant Renderer
    participant Main as Main Process
    participant System as System Theme
    
    User->>Renderer: Select theme in settings
    Renderer->>Renderer: Call setActive(themeId)
    Renderer->>Renderer: Apply theme tokens to DOM
    Renderer->>Main: IPC: theme:set-source
    Main->>Main: Set nativeTheme.themeSource
    System-->>Main: System theme changes
    Main->>Renderer: Broadcast: theme:system-changed
    Renderer->>Renderer: Call onSystemChanged callback
    Renderer->>Renderer: Apply system theme via applyAppearance()
    Renderer->>Renderer: Update DOM with new theme tokens
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~65 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ 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 accurately and concisely describes the main feature introduced in the changeset—a layered theme system for plugins—which aligns with the primary objective of implementing a complete theme system with validation, registry store, UI integration, and Electron synchronization.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/theme-system

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.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@tomymaritano

Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Mar 11, 2026

Copy link
Copy Markdown
✅ Actions performed

Full review triggered.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 10

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/desktop/src/preload/index.ts`:
- Around line 949-952: The implementation of theme.setSource uses a loose string
parameter; change its signature to setSource: (source: 'dark' | 'light' |
'system') => void to match the interface and provide compile-time safety,
leaving the ipcRenderer.send('theme:set-source', source) call unchanged; update
any local callers if they pass other strings to conform to the union type.

In `@apps/desktop/src/renderer/App.tsx`:
- Around line 87-96: The effect that restores the saved theme (useEffect
watching appearance?.activeThemeId) can miss restoring if plugins register
themes later; update the effect to also react to themeRegistryStore
changes—either by adding the themes array length (e.g.,
themeRegistryStore.getState().themes.length) to the dependency list or by
subscribing to themeRegistryStore inside the effect and re-checking
existence—then call themeRegistryStore.getState().setActive(savedThemeId) once
the savedThemeId appears in themeRegistryStore.getState().themes; ensure you
reference appearance?.activeThemeId, themeRegistryStore.getState().themes, and
setActive when implementing the fix.

In `@apps/desktop/src/renderer/hooks/useAppearanceSettings.ts`:
- Around line 8-18: When theme === 'system' the applyAppearance(theme,
accentColor, zoomLevel, isDark) function currently falls back to
window.matchMedia on re-applies and ignores the IPC-supplied nativeTheme;
persist the IPC value (e.g., stash the incoming isDark from preload/IPC into a
module-level variable like initialIsDark or expose a preload getter such as
getNativeThemeSnapshot) and update applyAppearance to prefer that persisted
snapshot when theme === 'system' instead of calling window.matchMedia so
subsequent accent/zoom updates continue to use the main-process nativeTheme
state.
- Around line 8-18: The applyAppearance function currently writes the resolved
light/dark state to document.documentElement via data-theme, which conflicts
with useThemeOverrides writing plugin theme values; change applyAppearance to
set a separate attribute (e.g.,
document.documentElement.setAttribute('data-color-scheme', resolved)) and update
any consumers that read data-theme to instead read data-color-scheme for
color-scheme behavior; alternatively, have useThemeOverrides write its
plugin-provided value to a distinct attribute like data-plugin-theme rather than
data-theme so plugin theme identity is not clobbered when applyAppearance (with
params theme, accentColor, zoomLevel) reruns.

In `@apps/desktop/src/renderer/pages/settings/sections/AppearanceSection.tsx`:
- Line 10: The import order is wrong per linting rules: move the external
package import "import { themeRegistryStore } from '@readied/plugin-api';" so it
appears before the relative imports from ../../../stores/settings (e.g., the
settings-related import used in AppearanceSection). Update the top of
AppearanceSection.tsx to place the `@readied/plugin-api` import above the relative
settings import and ensure your editor/formatter preserves the new order.

In `@docs/plans/2026-03-11-theme-system-implementation.md`:
- Line 13: The document jumps from an h1 to an h3 at the heading "Task 1: Define
theme types and token whitelist"; change that heading (and other task headings
at the same level) from ### to ## to restore proper heading hierarchy (e.g.,
update "### Task 1: Define theme types and token whitelist" to "## Task 1:
Define theme types and token whitelist" and do the same for subsequent "Task"
headings).

In `@packages/plugin-api/src/lifecycle/PluginRegistry.ts`:
- Around line 275-281: The registerTheme implementation ignores the boolean
return from themeRegistryStore.getState().register, so failures when a theme has
no valid tokens are swallowed; update registerTheme (the registerTheme arrow
function) to capture the result of themeRegistryStore.getState().register({... ,
pluginId: id}) and handle failures: either call processLogger.warn /
console.warn (or the existing logger) with context including id and theme.id
when register returns false, or change the API to return a tuple/object like {
success: boolean, dispose: () => void } so callers receive the boolean result
alongside the disposer; ensure you still call
themeRegistryStore.getState().unregister(theme.id) in the disposer regardless of
result.

In `@packages/plugin-api/src/theme/themeRegistryStore.ts`:
- Around line 36-38: The log call in themeRegistryStore.ts uses console.debug
which is disallowed by static analysis; replace the console.debug call that logs
`[ThemeRegistry] Registered: "${theme.name}" (${Object.keys(validTokens).length}
tokens)` with an allowed method (e.g., console.warn or console.error) or remove
the log entirely for production builds—locate the statement that references
theme.name and validTokens in the theme registration code and update or strip
the logging accordingly.

In `@packages/plugin-api/src/theme/useThemeOverrides.ts`:
- Around line 12-15: getSnapshot currently returns a fresh object every call
causing useSyncExternalStore to always detect a changed snapshot; modify
getSnapshot (used with useSyncExternalStore and themeRegistryStore) to memoize
the returned value by caching the last activeThemeId and themes and only
create/return a new object when either activeThemeId or themes differs from the
cached values (or alternatively return a primitive/tuple like [activeThemeId,
themes] or just activeThemeId if only that is needed) so the reference remains
stable across unchanged state and prevents unnecessary re-renders.

In `@packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts`:
- Around line 849-854: Prettier formatting failed across this file; run the
formatter and fix formatting issues (especially the method signature and any
broken method chains around getTagsPendingSync in the SQLiteNoteRepository
class) by executing the project's formatting commands (e.g., pnpm format:check
to see failures and prettier --write to apply fixes) so the file conforms to the
repo Prettier rules and CI passes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 062aef14-839b-45ea-b848-39a2fd3c2432

📥 Commits

Reviewing files that changed from the base of the PR and between 73f9938 and f4e904c.

📒 Files selected for processing (18)
  • apps/desktop/src/main/index.ts
  • apps/desktop/src/main/services/syncService.ts
  • apps/desktop/src/preload/index.ts
  • apps/desktop/src/renderer/App.tsx
  • apps/desktop/src/renderer/hooks/useAppearanceSettings.ts
  • apps/desktop/src/renderer/pages/settings/sections/AppearanceSection.tsx
  • apps/desktop/src/renderer/stores/settings/schema.ts
  • docs/plans/2026-03-11-theme-system-implementation.md
  • packages/api/src/routes/sync.ts
  • packages/plugin-api/src/index.ts
  • packages/plugin-api/src/lifecycle/PluginRegistry.ts
  • packages/plugin-api/src/theme/themeRegistryStore.ts
  • packages/plugin-api/src/theme/themeTypes.ts
  • packages/plugin-api/src/theme/useThemeOverrides.ts
  • packages/plugin-api/src/types.ts
  • packages/plugin-api/tests/themeRegistryStore.test.ts
  • packages/plugin-api/tests/themeTypes.test.ts
  • packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts

Comment on lines +949 to +952
theme: {
setSource: (source: string) => {
ipcRenderer.send('theme:set-source', source);
},

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 | 🟡 Minor

Type mismatch between interface declaration and implementation.

The interface declares setSource: (source: 'dark' | 'light' | 'system') => void (line 690), but the implementation uses (source: string). While the main process validates the value, enforcing the type in the preload provides better type safety.

🔧 Proposed fix
   theme: {
-    setSource: (source: string) => {
+    setSource: (source: 'dark' | 'light' | 'system') => {
       ipcRenderer.send('theme:set-source', source);
     },
📝 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
theme: {
setSource: (source: string) => {
ipcRenderer.send('theme:set-source', source);
},
theme: {
setSource: (source: 'dark' | 'light' | 'system') => {
ipcRenderer.send('theme:set-source', source);
},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/preload/index.ts` around lines 949 - 952, The implementation
of theme.setSource uses a loose string parameter; change its signature to
setSource: (source: 'dark' | 'light' | 'system') => void to match the interface
and provide compile-time safety, leaving the
ipcRenderer.send('theme:set-source', source) call unchanged; update any local
callers if they pass other strings to conform to the union type.

Comment thread apps/desktop/src/renderer/App.tsx Outdated
Comment on lines +87 to +96
useEffect(() => {
const savedThemeId = appearance?.activeThemeId;
if (savedThemeId) {
// Only restore if the theme is actually registered (plugin loaded)
const exists = themeRegistryStore.getState().themes.some(t => t.id === savedThemeId);
if (exists) {
themeRegistryStore.getState().setActive(savedThemeId);
}
}
}, [appearance?.activeThemeId]);

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

Theme restoration may fail due to race condition with plugin loading.

This effect runs when activeThemeId changes, but it doesn't re-run when plugins register their themes. If the component mounts before plugins finish activating, the exists check (line 91) will fail because themes is still empty, and the saved theme will never be restored.

The dependency array [appearance?.activeThemeId] doesn't include the themes array, so even when a plugin registers the theme later, this effect won't re-execute.

Consider subscribing to themeRegistryStore changes or adding themes.length to dependencies:

🔧 Suggested approach: Subscribe to registry changes
+import { useSyncExternalStore } from 'react';

 // Restore saved plugin theme on startup
 const appearance = useSettingsStore(selectAppearance);
+const registeredThemes = useSyncExternalStore(
+  themeRegistryStore.subscribe,
+  () => themeRegistryStore.getState().themes
+);

 useEffect(() => {
   const savedThemeId = appearance?.activeThemeId;
   if (savedThemeId) {
     // Only restore if the theme is actually registered (plugin loaded)
-    const exists = themeRegistryStore.getState().themes.some(t => t.id === savedThemeId);
+    const exists = registeredThemes.some(t => t.id === savedThemeId);
     if (exists) {
       themeRegistryStore.getState().setActive(savedThemeId);
     }
   }
-}, [appearance?.activeThemeId]);
+}, [appearance?.activeThemeId, registeredThemes]);

This ensures the effect re-runs when plugins register themes, allowing proper restoration once the theme becomes available.

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

In `@apps/desktop/src/renderer/App.tsx` around lines 87 - 96, The effect that
restores the saved theme (useEffect watching appearance?.activeThemeId) can miss
restoring if plugins register themes later; update the effect to also react to
themeRegistryStore changes—either by adding the themes array length (e.g.,
themeRegistryStore.getState().themes.length) to the dependency list or by
subscribing to themeRegistryStore inside the effect and re-checking
existence—then call themeRegistryStore.getState().setActive(savedThemeId) once
the savedThemeId appears in themeRegistryStore.getState().themes; ensure you
reference appearance?.activeThemeId, themeRegistryStore.getState().themes, and
setActive when implementing the fix.

Comment on lines +8 to +18
function applyAppearance(
theme: string,
accentColor: string,
zoomLevel: string,
isDark?: boolean
): void {
let resolved: string;
if (theme === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
resolved =
(isDark ?? window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
} else {

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 whether the theme bridge exposes a current system-theme value
# or only future change notifications.
rg -n -C3 "setSource|onSystemChanged|theme:system-changed|shouldUseDarkColors|themeSource" \
  apps/desktop/src/main/index.ts \
  apps/desktop/src/preload/index.ts \
  apps/desktop/src/renderer/hooks/useAppearanceSettings.ts

Repository: tomymaritano/readide

Length of output: 4031


🏁 Script executed:

cat -n apps/desktop/src/renderer/hooks/useAppearanceSettings.ts

Repository: tomymaritano/readide

Length of output: 2881


Keep 'system' mode on a single source of truth.

Line 58 re-applies appearance without the IPC-supplied isDark whenever accent or zoom changes. When theme='system', this causes line 17 to fall back to window.matchMedia instead of using the main-process nativeTheme state. Persist the IPC value locally or expose an initial getter/snapshot from preload to avoid diverging from the main process state during updates.

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

In `@apps/desktop/src/renderer/hooks/useAppearanceSettings.ts` around lines 8 -
18, When theme === 'system' the applyAppearance(theme, accentColor, zoomLevel,
isDark) function currently falls back to window.matchMedia on re-applies and
ignores the IPC-supplied nativeTheme; persist the IPC value (e.g., stash the
incoming isDark from preload/IPC into a module-level variable like initialIsDark
or expose a preload getter such as getNativeThemeSnapshot) and update
applyAppearance to prefer that persisted snapshot when theme === 'system'
instead of calling window.matchMedia so subsequent accent/zoom updates continue
to use the main-process nativeTheme state.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify all writers/readers of the root `data-theme` attribute.
rg -n -C2 "setAttribute\\(['\"]data-theme['\"]|dataset\\.theme\\s*=|\\[data-theme"

Repository: tomymaritano/readide

Length of output: 9107


🏁 Script executed:

# Check what values theme.colorScheme can have and understand the plugin theme layer
cat -n packages/plugin-api/src/theme/useThemeOverrides.ts | head -40

Repository: tomymaritano/readide

Length of output: 1598


🏁 Script executed:

# Inspect useAppearanceSettings hook dependencies and full implementation
cat -n apps/desktop/src/renderer/hooks/useAppearanceSettings.ts

Repository: tomymaritano/readide

Length of output: 2881


🏁 Script executed:

# Check useTheme.ts to see if there's a relationship
cat -n apps/desktop/src/renderer/hooks/useTheme.ts | head -40

Repository: tomymaritano/readide

Length of output: 1464


Separate the color-scheme attribute from the plugin-theme attribute.

Both useAppearanceSettings.ts (line 21) and useThemeOverrides.ts (line 27) write to documentElement[data-theme]. The first sets it to 'dark' | 'light' and reruns whenever appearance settings change (theme, accent color, or zoom level). The second sets it to a plugin-provided theme.colorScheme. Because applyAppearance has [theme, accentColor, zoomLevel] as dependencies, changes to zoom or accent color will unconditionally overwrite the plugin theme value, losing the plugin-supplied theme state.

Use distinct attributes such as data-color-scheme and data-plugin-theme, or keep plugin theme identity out of data-theme.

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

In `@apps/desktop/src/renderer/hooks/useAppearanceSettings.ts` around lines 8 -
18, The applyAppearance function currently writes the resolved light/dark state
to document.documentElement via data-theme, which conflicts with
useThemeOverrides writing plugin theme values; change applyAppearance to set a
separate attribute (e.g.,
document.documentElement.setAttribute('data-color-scheme', resolved)) and update
any consumers that read data-theme to instead read data-color-scheme for
color-scheme behavior; alternatively, have useThemeOverrides write its
plugin-provided value to a distinct attribute like data-plugin-theme rather than
data-theme so plugin theme identity is not clobbered when applyAppearance (with
params theme, accentColor, zoomLevel) reruns.

import { useSyncExternalStore } from 'react';
import { useSettingsStore, selectAppearance } from '../../../stores/settings';
import { usePerformanceStore } from '../../../stores/performanceStore';
import { themeRegistryStore } from '@readied/plugin-api';

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 | 🟡 Minor

Fix import order per linting rules.

Static analysis indicates @readied/plugin-api import should occur before relative imports from ../../../stores/settings.

🔧 Proposed fix
 import { useSyncExternalStore } from 'react';
+import { themeRegistryStore } from '@readied/plugin-api';
 import { useSettingsStore, selectAppearance } from '../../../stores/settings';
 import { usePerformanceStore } from '../../../stores/performanceStore';
-import { themeRegistryStore } from '@readied/plugin-api';
 import { SettingGroup } from '../components/SettingGroup';
📝 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
import { themeRegistryStore } from '@readied/plugin-api';
import { useSyncExternalStore } from 'react';
import { themeRegistryStore } from '@readied/plugin-api';
import { useSettingsStore, selectAppearance } from '../../../stores/settings';
import { usePerformanceStore } from '../../../stores/performanceStore';
import { SettingGroup } from '../components/SettingGroup';
🧰 Tools
🪛 GitHub Check: lint

[warning] 10-10:
@readied/plugin-api import should occur before import of ../../../stores/settings

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

In `@apps/desktop/src/renderer/pages/settings/sections/AppearanceSection.tsx` at
line 10, The import order is wrong per linting rules: move the external package
import "import { themeRegistryStore } from '@readied/plugin-api';" so it appears
before the relative imports from ../../../stores/settings (e.g., the
settings-related import used in AppearanceSection). Update the top of
AppearanceSection.tsx to place the `@readied/plugin-api` import above the relative
settings import and ensure your editor/formatter preserves the new order.


---

### Task 1: Define theme types and token whitelist

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 | 🟡 Minor

Fix heading hierarchy for markdown compliance.

Static analysis indicates the heading jumps from # (h1) directly to ### (h3), skipping h2. Consider using ## for "Task 1" and subsequent task headings to maintain proper document structure.

🧰 Tools
🪛 markdownlint-cli2 (0.21.0)

[warning] 13-13: Heading levels should only increment by one level at a time
Expected: h2; Actual: h3

(MD001, heading-increment)

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

In `@docs/plans/2026-03-11-theme-system-implementation.md` at line 13, The
document jumps from an h1 to an h3 at the heading "Task 1: Define theme types
and token whitelist"; change that heading (and other task headings at the same
level) from ### to ## to restore proper heading hierarchy (e.g., update "###
Task 1: Define theme types and token whitelist" to "## Task 1: Define theme
types and token whitelist" and do the same for subsequent "Task" headings).

Comment on lines +275 to +281
registerTheme: (theme): (() => void) => {
themeRegistryStore.getState().register({
...theme,
pluginId: id,
});
return () => themeRegistryStore.getState().unregister(theme.id);
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

The register() return value is ignored.

themeRegistryStore.register() returns false if the theme has no valid tokens (see themeRegistryStore.ts lines 27-31). The current implementation doesn't propagate this failure to the plugin.

Consider either:

  1. Logging a warning if registration fails
  2. Returning a result indicator alongside the disposer
♻️ Proposed enhancement to log registration failures
       registerTheme: (theme): (() => void) => {
-        themeRegistryStore.getState().register({
+        const success = themeRegistryStore.getState().register({
           ...theme,
           pluginId: id,
         });
+        if (!success) {
+          console.warn(`[${id}] Theme registration failed for "${theme.id}" (no valid tokens)`);
+        }
         return () => themeRegistryStore.getState().unregister(theme.id);
       },
📝 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
registerTheme: (theme): (() => void) => {
themeRegistryStore.getState().register({
...theme,
pluginId: id,
});
return () => themeRegistryStore.getState().unregister(theme.id);
},
registerTheme: (theme): (() => void) => {
const success = themeRegistryStore.getState().register({
...theme,
pluginId: id,
});
if (!success) {
console.warn(`[${id}] Theme registration failed for "${theme.id}" (no valid tokens)`);
}
return () => themeRegistryStore.getState().unregister(theme.id);
},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/plugin-api/src/lifecycle/PluginRegistry.ts` around lines 275 - 281,
The registerTheme implementation ignores the boolean return from
themeRegistryStore.getState().register, so failures when a theme has no valid
tokens are swallowed; update registerTheme (the registerTheme arrow function) to
capture the result of themeRegistryStore.getState().register({... , pluginId:
id}) and handle failures: either call processLogger.warn / console.warn (or the
existing logger) with context including id and theme.id when register returns
false, or change the API to return a tuple/object like { success: boolean,
dispose: () => void } so callers receive the boolean result alongside the
disposer; ensure you still call
themeRegistryStore.getState().unregister(theme.id) in the disposer regardless of
result.

Comment on lines +36 to +38
console.debug(
`[ThemeRegistry] Registered: "${theme.name}" (${Object.keys(validTokens).length} tokens)`
);

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 | 🟡 Minor

Replace console.debug with an allowed console method.

Static analysis indicates only console.warn and console.error are allowed. Consider using console.info or removing the debug log in production builds.

🔧 Proposed fix
-    console.debug(
+    console.info(
       `[ThemeRegistry] Registered: "${theme.name}" (${Object.keys(validTokens).length} tokens)`
     );

Or remove the log entirely if it's only useful during development.

🧰 Tools
🪛 GitHub Check: lint

[warning] 36-36:
Unexpected console statement. Only these console methods are allowed: warn, error

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

In `@packages/plugin-api/src/theme/themeRegistryStore.ts` around lines 36 - 38,
The log call in themeRegistryStore.ts uses console.debug which is disallowed by
static analysis; replace the console.debug call that logs `[ThemeRegistry]
Registered: "${theme.name}" (${Object.keys(validTokens).length} tokens)` with an
allowed method (e.g., console.warn or console.error) or remove the log entirely
for production builds—locate the statement that references theme.name and
validTokens in the theme registration code and update or strip the logging
accordingly.

Comment on lines +12 to +15
const getSnapshot = () => ({
activeThemeId: themeRegistryStore.getState().activeThemeId,
themes: themeRegistryStore.getState().themes,
});

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

getSnapshot creates new object references, causing unnecessary re-renders.

useSyncExternalStore compares snapshots by reference. Since getSnapshot creates a new object on every call, the hook will re-render on every store notification regardless of whether activeThemeId or themes actually changed.

🐛 Proposed fix: memoize or use shallow comparison
-const getSnapshot = () => ({
-  activeThemeId: themeRegistryStore.getState().activeThemeId,
-  themes: themeRegistryStore.getState().themes,
-});
+const getSnapshot = () => themeRegistryStore.getState();

Alternatively, if you only need activeThemeId for the dependency, subscribe to that specifically:

+const getActiveThemeId = () => themeRegistryStore.getState().activeThemeId;
+const getThemes = () => themeRegistryStore.getState().themes;

 export function useThemeOverrides(): void {
-  const state = useSyncExternalStore(subscribe, getSnapshot);
+  const activeThemeId = useSyncExternalStore(subscribe, getActiveThemeId);
+  const themes = useSyncExternalStore(subscribe, getThemes);

   useEffect(() => {
     // ...
-  }, [state.activeThemeId, state.themes]);
+  }, [activeThemeId, themes]);
 }
📝 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 getSnapshot = () => ({
activeThemeId: themeRegistryStore.getState().activeThemeId,
themes: themeRegistryStore.getState().themes,
});
const getSnapshot = () => themeRegistryStore.getState();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/plugin-api/src/theme/useThemeOverrides.ts` around lines 12 - 15,
getSnapshot currently returns a fresh object every call causing
useSyncExternalStore to always detect a changed snapshot; modify getSnapshot
(used with useSyncExternalStore and themeRegistryStore) to memoize the returned
value by caching the last activeThemeId and themes and only create/return a new
object when either activeThemeId or themes differs from the cached values (or
alternatively return a primitive/tuple like [activeThemeId, themes] or just
activeThemeId if only that is needed) so the reference remains stable across
unchanged state and prevents unnecessary re-renders.

Comment on lines +849 to +854
getTagsPendingSync(
limit: number
): Array<{
tag: { id: number; uuid: string; name: string; color: string | null };
localVersion: number;
}> {

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 | 🟡 Minor

Fix Prettier formatting to pass CI.

The pipeline indicates a Prettier formatting check failure on this file. The changes here and throughout the file appear to be formatting-related (breaking method chains across lines). Run pnpm format:check locally to identify the exact formatting issues, then apply fixes with prettier --write.

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

In `@packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts` around
lines 849 - 854, Prettier formatting failed across this file; run the formatter
and fix formatting issues (especially the method signature and any broken method
chains around getTagsPendingSync in the SQLiteNoteRepository class) by executing
the project's formatting commands (e.g., pnpm format:check to see failures and
prettier --write to apply fixes) so the file conforms to the repo Prettier rules
and CI passes.

- Fix race condition in theme restore by subscribing to themeRegistryStore
  changes via useSyncExternalStore (App.tsx)
- Separate data-color-scheme (dark/light) from data-theme (plugin themes)
  to prevent attribute conflicts between useAppearanceSettings and
  useThemeOverrides
- Persist IPC nativeTheme isDark value to avoid matchMedia fallback on
  accent/zoom re-applies (useAppearanceSettings.ts)
- Fix unnecessary re-renders in useThemeOverrides by using separate
  subscriptions for activeThemeId and themes instead of creating new
  object references
- Fix type mismatch in preload theme.setSource to use union type
- Fix import order in AppearanceSection.tsx per lint rules
- Log warning on failed theme registration in PluginRegistry
- Replace disallowed console.debug with guarded console.warn in
  themeRegistryStore
- Fix Prettier formatting in SQLiteNoteRepository.ts
- Update CSS selectors in tokens.css and code-highlight.css to use
  data-color-scheme

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@tomymaritano

Copy link
Copy Markdown
Collaborator Author

CodeRabbit Review Comments — All Resolved ✅

All 9 CodeRabbit comments have been reviewed. Here's what was addressed:

Fixed (commit c099dbd):

  1. 🟠 MAJOR — Race condition in theme restore (App.tsx:96): Added useSyncExternalStore subscription to themeRegistryStore.themes. The restore effect now re-runs when plugins register themes after mount, ensuring saved themes are properly restored.

  2. 🟠 MAJOR — data-theme attribute conflict (useAppearanceSettings.ts:18): Separated concerns — useAppearanceSettings now writes to data-color-scheme (dark/light resolved value), while useThemeOverrides writes to data-theme (plugin themes). Updated CSS selectors in tokens.css and code-highlight.css to use [data-color-scheme="light"]. Also persisted IPC nativeTheme.isDark value to avoid matchMedia fallback on accent/zoom re-applies.

  3. 🟠 MAJOR — Unnecessary re-renders (useThemeOverrides.ts:15): Replaced single getSnapshot (which created new object references every call) with two separate useSyncExternalStore subscriptions for activeThemeId and themes. Added cleanup to remove data-theme when no plugin theme is active.

  4. 🟡 Minor — Type mismatch (preload/index.ts:952): Changed setSource: (source: string) to setSource: (source: 'dark' | 'light' | 'system') matching the interface declaration.

  5. 🟡 Minor — Import order (AppearanceSection.tsx:10): Moved @readied/plugin-api import before relative imports per lint rules.

  6. 🔵 Nitpick — Ignored return value (PluginRegistry.ts:281): Captured boolean return from themeRegistryStore.register() and logs warning if registration fails.

  7. 🟡 Minor — Disallowed console method (themeRegistryStore.ts:38): Replaced console.debug with production-guarded console.warn.

  8. 🟡 Minor — Formatting (SQLiteNoteRepository.ts:854): Applied Prettier auto-fix.

Skipped (documentation only):

  • 1 comment on implementation plan doc.

Verification: 167 tests pass, typecheck clean across all 3 phases.

@tomymaritano tomymaritano merged commit d773955 into develop Mar 11, 2026
4 checks passed
@tomymaritano tomymaritano deleted the feature/theme-system branch March 11, 2026 23:55
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