Skip to content

feat(theme): scoped theming with ThemeScope component#768

Draft
paanSinghCoder wants to merge 3 commits into
mainfrom
feat/scoped-theming
Draft

feat(theme): scoped theming with ThemeScope component#768
paanSinghCoder wants to merge 3 commits into
mainfrom
feat/scoped-theming

Conversation

@paanSinghCoder
Copy link
Copy Markdown
Contributor

@paanSinghCoder paanSinghCoder commented Apr 30, 2026

Summary

Screen.Recording.2026-04-30.at.4.15.38.PM.mov

Check usage in /examples/theme-scoped

Any element with data-theme (or wrapped in <ThemeScope>) creates an isolated theme region. Descendants resolve all --rs-color-* tokens, color-scheme, and the smooth theme-switch transition from the nearest scoped ancestor.

The CSS alias chain (--rs-color-*--rs-neutral-*--rs-gray-*) now redeclares each layer on [data-theme="light"] / [data-theme="dark"] so scopes re-anchor cleanly. Adds matching color-scheme rules so native UI (form controls, scrollbars) follows the scope.

Why a separate ThemeScope (not extending ThemeProvider)?

ThemeProvider bundles global-only concerns: writes to document.documentElement, localStorage persistence, prefers-color-scheme listener, inline SSR-flash script, and an explicit "nested providers are no-ops" guard. Reusing it for scopes would mean gating every effect on an isScope flag, removing the nesting guard, and reworking context — same internal split, just behind one export. ThemeScope is ~30 lines, no state, no effects, does only what scoping needs.

Why no localStorage on ThemeScope?

Stateless by design. Scopes share no semantic identity, so a single storage key wouldn't fit, and per-scope persistence would reintroduce SSR flash at the scope level (each scope would need its own inline pre-hydration script). Consumers manage state themselves; we deferred a useScopedTheme helper hook until there's a real ask.

Test plan

  • pnpm test in packages/raystack (9 new ThemeScope tests pass)
  • Visit /examples/scoped-theme — global and scoped switchers toggle independently; both panels re-theme correctly
  • Bare <div data-theme="dark"> inside a light page — native UI (checkboxes, scrollbars) follows the scope

🤖 Generated with Claude Code

Any element with `data-theme` (or wrapped in `<ThemeScope>`) now creates an isolated theme region. Descendants resolve all `--rs-color-*` tokens, `color-scheme`, and the smooth theme-switch transition from the nearest scoped ancestor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 30, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
apsara Ready Ready Preview, Comment May 15, 2026 10:10am

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 30, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

This PR introduces scoped theming for the Raystack design system. It enables nested ThemeProvider instances to create independent theme regions by rendering a wrapper <div> with data-theme and optional data-accent-color, data-gray-color, and data-style attributes. When ThemeProvider detects a parent context, it switches to scoped mode instead of writing to the document root, allowing multiple theme regions within a single page. The implementation includes comprehensive tests validating attribute emission, a working example page with two independent scoped regions, and documentation explaining both bare attribute and nested provider approaches.

Sequence Diagram

sequenceDiagram
  participant App as Page Component
  participant Theme as ThemeProvider (outer)
  participant Scoped as Scoped Wrapper (inner)
  participant DOM as DOM Elements
  
  App->>Theme: useTheme() for global theme
  Theme->>DOM: writes data-theme to document root
  
  App->>Theme: render nested ThemeProvider
  Theme->>Scoped: detects parent context, render Scoped
  Scoped->>DOM: writes data-theme to wrapper div
  Scoped->>DOM: writes data-accent-color if accentColor prop
  Scoped->>DOM: writes data-gray-color if grayColor prop
  Scoped->>DOM: writes data-style if style prop
  
  DOM->>DOM: CSS [data-theme="light/dark"] selectors resolve tokens
  DOM->>App: child components render with scoped tokens
Loading

Possibly related issues

  • #575: Container-scoped theming initiative—both changes replace document-root selectors (:root, html[data-theme]) with attribute-scoped selectors ([data-theme="light/dark"]) to enable theme regions independent of the document root.

Suggested reviewers

  • rsbh
  • rohanchkrabrty
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(theme): scoped theming with ThemeScope component' accurately and specifically describes the main feature addition - introducing scoped theming functionality via a new ThemeScope component.
Description check ✅ Passed The description comprehensively explains the scoped theming feature, including the use case, implementation rationale, design decisions, and test plan, all directly related to the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


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
Contributor

@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: 2

🤖 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/www/src/app/examples/scoped-theme/page.tsx`:
- Around line 17-23: The panelStyle object uses a spacing token for borderRadius
(borderRadius: 'var(--rs-space-3)') which teaches the wrong token family; update
the borderRadius value to use the appropriate radius token (e.g., replace
var(--rs-space-3) with the project's radius token such as var(--rs-radius-3) or
the correct --rs-radius-<n>), keeping the rest of panelStyle unchanged so the
example demonstrates the proper radius token usage.

In `@packages/raystack/styles/primitives/appearance.css`:
- Around line 13-20: The CSS unconditionally applies color-scheme when
[data-theme] is present, bypassing ThemeProvider's enableColorScheme flag;
update the rules to only apply when an explicit opt-in marker is present (e.g.,
[data-enable-color-scheme="true"] or a class like .enable-color-scheme) so
ThemeProvider's enableColorScheme prop in ThemeProvider
(packages/raystack/components/theme-provider/theme.tsx) can control whether
native color-scheme is set; ensure the marker is added/removed by ThemeProvider
where enableColorScheme is read so disabling the prop actually prevents the CSS
color-scheme rules from applying.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 68a8df63-d9e9-4b91-beba-bc0d1b81312d

📥 Commits

Reviewing files that changed from the base of the PR and between a7e6705 and 64e762a.

📒 Files selected for processing (13)
  • apps/www/src/app/examples/scoped-theme/page.tsx
  • apps/www/src/content/docs/theme/overview/index.mdx
  • apps/www/src/content/docs/theme/overview/props.ts
  • packages/raystack/components/theme-provider/__tests__/theme-scope.test.tsx
  • packages/raystack/components/theme-provider/index.tsx
  • packages/raystack/components/theme-provider/theme-scope.tsx
  • packages/raystack/components/theme-provider/theme.tsx
  • packages/raystack/components/theme-provider/types.ts
  • packages/raystack/index.tsx
  • packages/raystack/styles/colors.css
  • packages/raystack/styles/primitives/accent.css
  • packages/raystack/styles/primitives/appearance.css
  • packages/raystack/styles/primitives/gray.css

Comment on lines +17 to +23
const panelStyle = {
minWidth: 320,
padding: 'var(--rs-space-7)',
borderRadius: 'var(--rs-space-3)',
border: '1px solid var(--rs-color-border-base-primary)',
backgroundColor: 'var(--rs-color-background-base-primary)'
};
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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use a radius token for borderRadius here.

borderRadius is pulling from the spacing scale. That makes the example teach the wrong token family and can drift if spacing and radius scales stop lining up.

Suggested fix
 const panelStyle = {
   minWidth: 320,
   padding: 'var(--rs-space-7)',
-  borderRadius: 'var(--rs-space-3)',
+  borderRadius: 'var(--rs-radius-3)',
   border: '1px solid var(--rs-color-border-base-primary)',
   backgroundColor: 'var(--rs-color-background-base-primary)'
 };
📝 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 panelStyle = {
minWidth: 320,
padding: 'var(--rs-space-7)',
borderRadius: 'var(--rs-space-3)',
border: '1px solid var(--rs-color-border-base-primary)',
backgroundColor: 'var(--rs-color-background-base-primary)'
};
const panelStyle = {
minWidth: 320,
padding: 'var(--rs-space-7)',
borderRadius: 'var(--rs-radius-3)',
border: '1px solid var(--rs-color-border-base-primary)',
backgroundColor: 'var(--rs-color-background-base-primary)'
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/www/src/app/examples/scoped-theme/page.tsx` around lines 17 - 23, The
panelStyle object uses a spacing token for borderRadius (borderRadius:
'var(--rs-space-3)') which teaches the wrong token family; update the
borderRadius value to use the appropriate radius token (e.g., replace
var(--rs-space-3) with the project's radius token such as var(--rs-radius-3) or
the correct --rs-radius-<n>), keeping the rest of panelStyle unchanged so the
example demonstrates the proper radius token usage.

Comment on lines +13 to +20
/* Native UI (form controls, scrollbars, text selection) follows the scoped theme */
[data-theme="light"] {
color-scheme: light;
}

[data-theme="dark"] {
color-scheme: dark;
}
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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

enableColorScheme={false} is bypassed by unconditional scoped color-scheme.

ThemeProvider still models enableColorScheme as a conditional behavior (packages/raystack/components/theme-provider/theme.tsx, Line 40 and Lines 90-99 / 258-271), but this CSS now forces native color-scheme whenever data-theme is present. That makes the prop effectively non-functional.

Please gate these rules behind an explicit opt-in attribute/class so disabling enableColorScheme is still respected.

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

In `@packages/raystack/styles/primitives/appearance.css` around lines 13 - 20, The
CSS unconditionally applies color-scheme when [data-theme] is present, bypassing
ThemeProvider's enableColorScheme flag; update the rules to only apply when an
explicit opt-in marker is present (e.g., [data-enable-color-scheme="true"] or a
class like .enable-color-scheme) so ThemeProvider's enableColorScheme prop in
ThemeProvider (packages/raystack/components/theme-provider/theme.tsx) can
control whether native color-scheme is set; ensure the marker is added/removed
by ThemeProvider where enableColorScheme is read so disabling the prop actually
prevents the CSS color-scheme rules from applying.

@paanSinghCoder
Copy link
Copy Markdown
Contributor Author

paanSinghCoder commented May 5, 2026

@rohilsurana Closing this PR for now. We will pick this later when we have defined usecase and better scoping.

ThemeProvider auto-detects scope vs root via context: nested usage renders
a wrapper `<div>` with `data-*` overrides instead of a no-op fragment.
Drops the ThemeScope component (and its render-prop/Base UI dep) so
consumers have one entry point. useTheme() inside a scope still returns
the root provider's state — scoped mode is stateless by design.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@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.

🧹 Nitpick comments (1)
packages/raystack/components/theme-provider/__tests__/theme-scope.test.tsx (1)

6-27: ⚡ Quick win

Isolate and restore global window mocks to avoid cross-suite leakage.

Lines 6–27 define localStorage and matchMedia at module scope without restoration, allowing mocks to leak across test suites and create order-dependent failures. Move setup into beforeAll and restore original property descriptors in afterAll.

Proposed refactor
-import { describe, expect, it, vi } from 'vitest';
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
 import { ThemeProvider } from '../theme';
 
-// jsdom doesn't ship these; the root ThemeProvider needs them on mount.
-Object.defineProperty(window, 'localStorage', {
-  value: {
-    getItem: vi.fn(),
-    setItem: vi.fn(),
-    removeItem: vi.fn(),
-    clear: vi.fn()
-  }
-});
-
-Object.defineProperty(window, 'matchMedia', {
-  writable: true,
-  value: vi.fn().mockImplementation(query => ({
-    matches: false,
-    media: query,
-    onchange: null,
-    addListener: vi.fn(),
-    removeListener: vi.fn(),
-    addEventListener: vi.fn(),
-    removeEventListener: vi.fn(),
-    dispatchEvent: vi.fn()
-  }))
-});
+const originalLocalStorage = Object.getOwnPropertyDescriptor(window, 'localStorage');
+const originalMatchMedia = Object.getOwnPropertyDescriptor(window, 'matchMedia');
+
+beforeAll(() => {
+  Object.defineProperty(window, 'localStorage', {
+    configurable: true,
+    value: {
+      getItem: vi.fn(),
+      setItem: vi.fn(),
+      removeItem: vi.fn(),
+      clear: vi.fn()
+    }
+  });
+
+  Object.defineProperty(window, 'matchMedia', {
+    configurable: true,
+    writable: true,
+    value: vi.fn().mockImplementation(query => ({
+      matches: false,
+      media: query,
+      onchange: null,
+      addListener: vi.fn(),
+      removeListener: vi.fn(),
+      addEventListener: vi.fn(),
+      removeEventListener: vi.fn(),
+      dispatchEvent: vi.fn()
+    }))
+  });
+});
+
+afterAll(() => {
+  if (originalLocalStorage) {
+    Object.defineProperty(window, 'localStorage', originalLocalStorage);
+  }
+  if (originalMatchMedia) {
+    Object.defineProperty(window, 'matchMedia', originalMatchMedia);
+  }
+});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/raystack/components/theme-provider/__tests__/theme-scope.test.tsx`
around lines 6 - 27, Move the global window mocks for localStorage and
matchMedia out of module scope and into a test lifecycle so they are restored
after the suite: capture the original descriptors for window.localStorage and
window.matchMedia, set the mocked implementations inside beforeAll (using the
current mock implementations from the diff), and restore the originals in
afterAll; reference the mocked symbols window.localStorage and window.matchMedia
and the lifecycle hooks beforeAll/afterAll to locate where to apply the changes
so other suites are not affected.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/raystack/components/theme-provider/__tests__/theme-scope.test.tsx`:
- Around line 6-27: Move the global window mocks for localStorage and matchMedia
out of module scope and into a test lifecycle so they are restored after the
suite: capture the original descriptors for window.localStorage and
window.matchMedia, set the mocked implementations inside beforeAll (using the
current mock implementations from the diff), and restore the originals in
afterAll; reference the mocked symbols window.localStorage and window.matchMedia
and the lifecycle hooks beforeAll/afterAll to locate where to apply the changes
so other suites are not affected.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 38904419-ed26-4f20-9c55-f34a82462c53

📥 Commits

Reviewing files that changed from the base of the PR and between 18a4060 and c54ada3.

📒 Files selected for processing (4)
  • apps/www/src/app/examples/scoped-theme/page.tsx
  • apps/www/src/content/docs/theme/overview/index.mdx
  • packages/raystack/components/theme-provider/__tests__/theme-scope.test.tsx
  • packages/raystack/components/theme-provider/theme.tsx
✅ Files skipped from review due to trivial changes (1)
  • apps/www/src/content/docs/theme/overview/index.mdx
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/www/src/app/examples/scoped-theme/page.tsx

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