Skip to content

feat(theme): revamped Theme - #893

Open
rohanchkrabrty wants to merge 6 commits into
mainfrom
worktree-theme-revamp
Open

rohanchkrabrty wants to merge 6 commits into
mainfrom
worktree-theme-revamp

Conversation

@rohanchkrabrty

@rohanchkrabrty rohanchkrabrty commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds ThemePreview, the element-mounted theme from RFC 004: tokens move off <html> onto a real element, so the root theme, a nested scope and a portal re-injection are the same component rendering the same attributes — which makes the theme server-renderable, allows more than one provider per page, and fixes scoped themes inside portals.
  • Rebuilds the token layer: every --rs-* declaration is wrapped in :where() so a consumer rule on the stable .rs-theme class wins without !important; spacing, effects and z-index move onto the theme selector so a scope can change them; adds --rs-scaling, a radius factor over a fixed base scale replacing the two hardcoded data-style scales, and panel/overlay/backdrop-filter tokens.
  • Adds seven independently seedable, controllable and persistable settings — appearance, accent, gray, radius, scaling, panel background, reduced motion — over useSyncExternalStore persistence gated on persistKey, plus a small pre-hydration script that patches its own parent and a :has() colour-scheme rule that writes nothing to <html>.
  • Adds theme re-injection across the thirteen portalling components, and a shared radius cva variant and CSS module; Image and Avatar migrate onto the five-value scale.
  • Publishes style-no-fonts.css alongside style.css, and adds a theme playground panel plus a docs page with a migration guide.
  • The existing Theme ships unchanged, so this is additive — no consumer migration is forced by this PR.

…nel tokens (RFC 004)

Adds `ThemePreview`, an element-mounted theme alongside the existing `Theme`,
implementing RFC 004. Tokens move off `<html>` onto a real element, so the root
theme, a nested scope and a portal re-injection are the same component
rendering the same attributes.

Token layer:
- every `--rs-*` declaration wrapped in `:where()`, so a consumer rule on the
  stable `.rs-theme` class wins without `!important` or load-order control
- spacing, effects and z-index move off `:root` onto the theme selector, which
  is what makes them scopable
- `--rs-scaling` multiplies spacing, radius, font sizes and line heights
- radius becomes a factor over a fixed base scale plus a pill length, replacing
  the two hardcoded `data-style` scales
- panel, overlay and backdrop-filter tokens; `data-panel-background` selects
  solid or translucent, defaulting to solid
- each accent gets its own selector, so `indigo` resets inside an orange scope
- `sage` joins the gray union

Component:
- seven independently seedable, controllable and persistable settings
- `useSyncExternalStore` persistence, gated on `persistKey`, with merge-on-write
  so themes may share a namespace
- a small pre-hydration script that patches its own parent, emitted only for a
  namespace's uncontrolled settings
- `:has()` colour-scheme rule on `<html>`, no JavaScript and no writes to it
- `hasBackground`, `isRoot`, `render`, `useThemePreview().root`

Portals and per-component radius:
- theme re-injection and a `container` prop across the thirteen portalling
  components, fixing scoped themes inside portals
- a shared `radius` cva variant and CSS module; `Image` and `Avatar` migrate
  onto the five-value scale

Stylesheets:
- `style-no-fonts.css` published alongside `style.css`
- `--rs-font-mono` reordered so JetBrains Mono precedes Menlo

Docs: a theme playground panel and a preview page with a migration guide.

The existing `Theme` ships unchanged, so this is additive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017vcxAxsBpKAvtUfDi2wFKr
@vercel

vercel Bot commented Sep 2, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated
apsara Ready Ready Preview Sep 17, 2026 5:48pm UTC

@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

Important

Review skipped

Too many files!

This PR contains 101 files, which is 1 over the limit of 100.

To get a review, reduce the PR to 100 files or fewer by splitting it into smaller PRs or changing its base branch.

Upgrade to a paid plan to raise the limit.

This review couldn't start because sufficient usage credits or metered capacity aren't available. Add credits or update usage-based reviews in the billing tab, then retry.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: a6fd41bf-3014-4b68-9772-31af62483746

📥 Commits

Reviewing files that changed from the base of the PR and between b131496 and 60c636f.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (101)
  • apps/www/src/components/demo/demo.tsx
  • apps/www/src/components/theme-panel-demo.tsx
  • apps/www/src/content/docs/theme/meta.json
  • apps/www/src/content/docs/theme/overview/index.mdx
  • apps/www/src/content/docs/theme/preview/demo.ts
  • apps/www/src/content/docs/theme/preview/index.mdx
  • apps/www/src/content/docs/theme/preview/props.ts
  • packages/raystack/CHANGELOG.md
  • packages/raystack/components/alert-dialog/alert-dialog-content.tsx
  • packages/raystack/components/announcement-bar/announcement-bar.module.css
  • packages/raystack/components/avatar/__tests__/avatar.test.tsx
  • packages/raystack/components/avatar/avatar.module.css
  • packages/raystack/components/avatar/avatar.tsx
  • packages/raystack/components/badge/badge.module.css
  • packages/raystack/components/badge/badge.tsx
  • packages/raystack/components/button/button.module.css
  • packages/raystack/components/button/button.tsx
  • packages/raystack/components/callout/callout.module.css
  • packages/raystack/components/callout/callout.tsx
  • packages/raystack/components/chip/chip.module.css
  • packages/raystack/components/chip/chip.tsx
  • packages/raystack/components/code-block/code-block.module.css
  • packages/raystack/components/color-picker/color-picker.module.css
  • packages/raystack/components/combobox/combobox-content.tsx
  • packages/raystack/components/combobox/combobox.module.css
  • packages/raystack/components/command/command-dialog.tsx
  • packages/raystack/components/command/command.module.css
  • packages/raystack/components/context-menu/context-menu-content.tsx
  • packages/raystack/components/dialog/dialog-content.tsx
  • packages/raystack/components/dialog/dialog.module.css
  • packages/raystack/components/drawer/drawer-content.tsx
  • packages/raystack/components/drawer/drawer.module.css
  • packages/raystack/components/editor/editor.module.css
  • packages/raystack/components/field/field.module.css
  • packages/raystack/components/filter-chip/filter-chip.module.css
  • packages/raystack/components/icon-button/icon-button.module.css
  • packages/raystack/components/icon-button/icon-button.tsx
  • packages/raystack/components/image/__tests__/image.test.tsx
  • packages/raystack/components/image/image.module.css
  • packages/raystack/components/image/image.tsx
  • packages/raystack/components/input/input.module.css
  • packages/raystack/components/input/input.tsx
  • packages/raystack/components/menu/menu-content.tsx
  • packages/raystack/components/menu/menu.module.css
  • packages/raystack/components/otp-field/otp-field.module.css
  • packages/raystack/components/popover/popover.module.css
  • packages/raystack/components/popover/popover.tsx
  • packages/raystack/components/preview-card/preview-card.module.css
  • packages/raystack/components/preview-card/preview-card.tsx
  • packages/raystack/components/select/select-content.tsx
  • packages/raystack/components/select/select.module.css
  • packages/raystack/components/slider/slider.module.css
  • packages/raystack/components/switch/switch.module.css
  • packages/raystack/components/tabs/tabs.module.css
  • packages/raystack/components/text-area/text-area.module.css
  • packages/raystack/components/text-area/text-area.tsx
  • packages/raystack/components/theme-preview/__tests__/data-slots.test.tsx
  • packages/raystack/components/theme-preview/__tests__/mocks.ts
  • packages/raystack/components/theme-preview/__tests__/script.test.ts
  • packages/raystack/components/theme-preview/__tests__/ssr.test.tsx
  • packages/raystack/components/theme-preview/__tests__/store.test.ts
  • packages/raystack/components/theme-preview/__tests__/theme-preview.test.tsx
  • packages/raystack/components/theme-preview/context.ts
  • packages/raystack/components/theme-preview/index.tsx
  • packages/raystack/components/theme-preview/portal.ts
  • packages/raystack/components/theme-preview/script.ts
  • packages/raystack/components/theme-preview/settings.ts
  • packages/raystack/components/theme-preview/store.ts
  • packages/raystack/components/theme-preview/switcher.tsx
  • packages/raystack/components/theme-preview/theme-preview.tsx
  • packages/raystack/components/theme-preview/use-system-appearance.ts
  • packages/raystack/components/toast/toast-provider.tsx
  • packages/raystack/components/toast/toast.module.css
  • packages/raystack/components/toggle/toggle.module.css
  • packages/raystack/components/tooltip/tooltip-content.tsx
  • packages/raystack/components/tooltip/tooltip.module.css
  • packages/raystack/components/tour/tour-content.tsx
  • packages/raystack/components/tour/tour.module.css
  • packages/raystack/hooks/index.tsx
  • packages/raystack/hooks/useCopyToClipboard.tsx
  • packages/raystack/index.tsx
  • packages/raystack/package.json
  • packages/raystack/rollup.config.mjs
  • packages/raystack/scripts/build-no-fonts-css.js
  • packages/raystack/shared/radius/index.ts
  • packages/raystack/shared/radius/radius.module.css
  • packages/raystack/shared/radius/radius.ts
  • packages/raystack/style.css
  • packages/raystack/styles/colors.css
  • packages/raystack/styles/effects.css
  • packages/raystack/styles/fonts-legacy.css
  • packages/raystack/styles/fonts.css
  • packages/raystack/styles/index.css
  • packages/raystack/styles/primitives/accent.css
  • packages/raystack/styles/primitives/appearance.css
  • packages/raystack/styles/primitives/gray.css
  • packages/raystack/styles/primitives/z-index.css
  • packages/raystack/styles/radius.css
  • packages/raystack/styles/spacing.css
  • packages/raystack/styles/theme.css
  • packages/raystack/styles/typography.css

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


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.

@pkg-pr-new

pkg-pr-new Bot commented Sep 2, 2026

Copy link
Copy Markdown

Open in StackBlitz

pnpm add https://pkg.pr.new/@raystack/apsara@893

commit: 60c636f

@rohanchkrabrty rohanchkrabrty changed the title feat(theme): element-mounted ThemePreview with radius and scaling (RFC 004) feat(theme): revamped Theme Sep 3, 2026
Comment thread packages/raystack/components/theme-preview/store.ts
Comment thread packages/raystack/components/theme-preview/theme-preview.tsx Outdated
Comment thread packages/raystack/components/theme-preview/script.ts Outdated
Comment thread packages/raystack/styles/theme.css
Comment thread packages/raystack/components/avatar/avatar.tsx Outdated
Comment thread packages/raystack/components/theme-preview/store.ts Outdated
Comment thread packages/raystack/components/drawer/drawer.module.css
Comment thread packages/raystack/styles/typography.css Outdated
Comment thread packages/raystack/components/theme-preview/theme-preview.tsx Outdated
Comment thread packages/raystack/components/theme-preview/__tests__/store.test.ts
@ravisuhag

ravisuhag commented Sep 12, 2026

Copy link
Copy Markdown
Member

reducedMotion doesn't do anything

theme.css collapses the duration tokens:

:where([data-reduced-motion="true"]) { --rs-duration-normal: 1ms; /* … */ }

effects.css sets them straight back:

:where(:root, [data-theme]) { --rs-duration-normal: 200ms; /* … */ }

Both are :where(), so both are zero specificity, and index.css imports effects.css after theme.css. A theme element carries both attributes, so effects.css wins:

getComputedStyle(el).getPropertyValue('--rs-duration-normal') // "200ms"

The @media (prefers-reduced-motion: reduce) branch has the same problem — media queries don't add specificity. So the forced path and the system path are both dead, and one of the seven settings is inert.

Moving the two blocks to the bottom of effects.css fixes it and keeps the durations in one file.

None of the 1,380 lines of tests catch this, because jsdom doesn't resolve the cascade. A browser check that reads the token off a data-reduced-motion="true" element would.

onValueChange — same root cause as your theme-preview.tsx:347 comment, opposite symptom

Shreyag found it firing when nobody asked. It also never fires for a key the consumer controls.

setValue drops controlled keys:

if (valueRef.current?.[key] !== undefined) continue;

and the callback comes from an effect diffing settled settings, which a controlled key can't move. So this does nothing and logs nothing:

<ThemePreview value={{ appearance: 'dark' }} onValueChange={console.log}>
  <ThemePreviewSwitcher />
</ThemePreview>

Both symptoms come from deriving the callback from state instead of from the request. Firing it inside setValue with the requested patch, controlled keys included, fixes both at once — hydration stops producing phantom events because hydration isn't a request, and controlled keys start reporting because they are.

Worth saying the per-key control is a good idea and nothing else has it. Radix Themes has no onChange at all, next-themes puts setTheme on the hook, Mantine's forceColorScheme is an override and is named like one. Being first here is fine. It just means there was no reference implementation to check the contract against.

22 components now import from components/theme-preview/

// button.tsx
import { radiusVariants } from '../theme-preview/radius';
// popover.tsx
import { type PortalContainer, useThemeInjection } from '../theme-preview/portal';

Button doesn't use the theme. It uses a radius map. PortalContainer is a Base UI type restated and has nothing to do with themes either. Both ended up under theme-preview/ because that's where they got written.

That directory is going to be renamed. Right now that's 22 files we'd have to touch on a day that should only be touching the theme. Moving radius.ts, radius.module.css and PortalContainer somewhere neutral costs a few import lines now.

This sits next to Shreyag's point about PortalContainer not being exported. Same type, different problem — it needs a home before it needs an export.

Smaller

  • styles/primitives/appearance.css still has [data-theme="light"], [data-theme="dark"] { transition: background-color 0.4s ease, color 0.4s ease } unwrapped. Every portal re-injection carries data-theme now, so all 13 pick it up. The duration is hardcoded rather than a token, so reduced motion can't touch it even after the fix above. And being unwrapped, a consumer can't override it without !important, which is the thing the :where() rewrite exists to avoid.

  • On the export list, agreed with Shreyag. Two extras: the nine arrays could be the single SETTING_VALUES object that already sits in settings.ts and isn't exported — a settings picker that maps over it stays correct when an eighth setting arrives. And radiusClasses / radiusVariants hand out CSS Modules hashed strings. That's build output, and exporting it means we're promising it stays stable. radiusClass() is the only one of the three with a real contract.

  • build-no-fonts-css.js doesn't fail when the regex matches nothing. If the font host changes or postcss stops hoisting the @imports, it writes an identical file called style-no-fonts.css and consumers load Google Fonts believing they don't. One if (removed === 0) process.exit(1) closes it.

  • The description calls this additive and it mostly is, but wrapping the token layer in :where() changes behaviour for existing Theme users too — a consumer rule on :root that used to lose to [data-theme="dark"] now wins. Same category as the font-mono reorder Shreyag flagged. Probably a release-note line rather than a change.

@ravisuhag

Copy link
Copy Markdown
Member

Separate from the bug list, one API question we should settle now rather than after release.

The seven settings arrive as one value bag and one defaultValue bag. We should flatten the controlled half.

Where the ecosystem sits — the split tracks whether the settings are a closed set of enums or an open tree of tokens:

Library Shape What it passes
Radix Themes <Theme> Flat appearance, accentColor, grayColor, radius, scaling, panelBackground, hasBackground
React Spectrum <Provider> Flat theme, colorScheme, scale, locale
next-themes Flat defaultTheme, forcedTheme, storageKey, enableSystem
Mantine Both theme={} for tokens, flat defaultColorScheme / forceColorScheme
MUI Object theme={createTheme(…)}
Ant Design Object theme={{ token, algorithm, components }}

Closed enums go flat. Token trees go in an object. We have seven closed enums, which is the Radix Themes case rather than the MUI one — and we already took hasBackground and six of the setting names from it.

Not full flat though, because the two props get used differently.

defaultValue is bulk. It's a cookie or a stored object, seven keys at once. A bag fits that.

value is one or two keys. A brand accent from app state, a forced dark scope. Flat fits that.

So:

<ThemePreview
  defaultValue={cookieSettings}   // bag: bulk seed, one line from storage
  accentColor={brand}             // flat: controlled, usually one key
  onValueChange={handleChange}
  persistKey="app-theme"
/>

Mantine ships this exact split, for the same reason.

Two things come along with it. The settings show up in autocomplete and get their own rows in the props table, instead of hiding one level down inside Partial<ThemeSettings>. And consumers stop handing us a fresh object literal on every render.

One thing to be clear about before it comes up: this does not let us delete useStableSettings. That holds the computed settings stable, and that object is rebuilt every render whatever the props look like.

What it costs: seven new prop names, and defaultValue sitting next to accentColor is a little asymmetric. The docs table, the demo and the playground panel all change. If we'd rather keep the one bag, that's defensible — Radix Themes is flat but has no controlled story at all, so nobody has solved our exact case.

We shouldn't add per-key callbacks. onAppearanceChange times seven is seven props for nothing, and changed already says which key moved.

The callback fix in the other comment matters more than this one. Without it, flattening just makes a broken contract easier to see.

@rohanchkrabrty

Copy link
Copy Markdown
Contributor Author

Separate from the bug list, one API question we should settle now rather than after release.

The seven settings arrive as one value bag and one defaultValue bag. We should flatten the controlled half.

@ravisuhag Thought about this approach during the RFC, but discarded it.

Flattening the keys adds noise without much value over the object-based API. Radix UI has 6 separate keys, each taking an object with value and defaultValue, along with 6 callbacks. This makes the API quite noisy. Source

The hybrid approach - flat keys with a single onChange callback and a shared defaultValue, also feels confusing and goes against our codebase convention of value, defaultValue, and onValueChange.

Let's keep the existing approach. Will address the discoverability by improving the docs and the callback bug

@rohanchkrabrty

Copy link
Copy Markdown
Contributor Author

1. reducedMotion - Moved both blocks to the end of effects.css.

2. onValueChange - Fires from setValue with the requested patch. Controlled keys report without applying; hydration and storage changes are silent. Tests for all three.

3. Imports from theme-preview/ - radius.ts and its CSS module moved to shared/radius, next to shared/gap, with Radius exported from there. The container prop and PortalContainer are out of this PR altogether; unrelated to the theme, will take it up separately.

4. Transition in appearance.css - Duration is --rs-duration-slow and the selector is wrapped in :where(). Kept it on portals: the colour only moves on an appearance switch, and a popup that snaps while the page fades looks broken. disableTransitionOnChange covers both.

5. Exports - Dropped radiusClasses and radiusVariants from the root, kept radiusClass. The nine arrays are replaced by THEME_SETTING_VALUES, with THEME_DEFAULT_SETTINGS beside it.

6. build-no-fonts-css.js - Exits 1 when the regex matches nothing.

7. :where() and existing Theme users - Agreed, release note. Added a theme entry to the CHANGELOG with this under "Changes for existing Theme users".

@ravisuhag

- Inline script resolves a seeded `system` appearance against the OS and
  runs without a `persistKey`, so a first visit on a dark OS paints dark
- Pill radius applies to controls only; surfaces use the plain token
- Drawer rounds its two free corners per side; flush corners stay square
- Storage write refuses to overwrite a newer schema and falls back to
  memory when refused
- `onValueChange` fires from the request, so hydration is silent and
  controlled keys report
- `hasBackground` heuristic reads the theme's own appearance from any source
- Remove the `container` prop and `PortalContainer`; separate PR
- Move the shared radius module to `shared/radius`
- Root exports: `THEME_SETTING_VALUES` and `THEME_DEFAULT_SETTINGS` replace
  the value arrays; drop hashed radius class exports; add script and types
- Add autoprefixer so `backdrop-filter` gets its `-webkit-` form
- Wrap the appearance transition in `:where()`; keep Menlo first
- Avatar has no default radius and follows the theme
- Docs, CHANGELOG entry and tests updated
# Conflicts:
#	packages/raystack/styles/primitives/appearance.css
Add `--rs-radius-thumb`: 9999px from `medium` up, 0 at `none` and `small`, mirroring Radix Themes. The Switch track and thumb and the Slider track, range and small thumb use max(radius-N, thumb) instead of the fixed `--rs-radius-full`, so they square off with the theme. Radio stays round. The radius demo shows a Switch.
…demos

Cut the prose to what a reader needs: settings, nesting, control, persistence, SSR, hook, customization and migration, each in a few sentences over a live example. New demos: panel background over a gradient, three-level nesting, a dark sidebar inside a light page, portals themed by their scope, and a switch-controlled scope.
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.

3 participants