Skip to content

feat(theme): revamped Theme - #893

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

rohanchkrabrty wants to merge 23 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 19, 2026 3:46pm 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 164 files, which is 64 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: 6f36bf02-6e33-4716-bf6f-90ae7bed21ef

📥 Commits

Reviewing files that changed from the base of the PR and between b131496 and 3a223aa.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (164)
  • .agents/skills/apsara/SKILL.md
  • .agents/skills/apsara/references/components.md
  • .agents/skills/apsara/references/setup.md
  • .agents/skills/apsara/references/styling.md
  • .agents/skills/apsara/references/theming.md
  • .agents/skills/apsara/references/tokens.md
  • apps/www/package.json
  • apps/www/src/app/layout.module.css
  • apps/www/src/app/layout.tsx
  • apps/www/src/components/demo/demo-playground.tsx
  • apps/www/src/components/demo/demo-preview.tsx
  • apps/www/src/components/demo/demo.tsx
  • apps/www/src/components/demo/no-inline.ts
  • apps/www/src/components/logo/logo.module.css
  • apps/www/src/components/theme-panel-demo.tsx
  • apps/www/src/components/theme-switcher/theme-toggle.tsx
  • apps/www/src/components/theme.module.css
  • apps/www/src/components/theme.tsx
  • apps/www/src/content/docs/(overview)/getting-started.mdx
  • apps/www/src/content/docs/(overview)/index.mdx
  • apps/www/src/content/docs/(overview)/styling.mdx
  • apps/www/src/content/docs/(overview)/upgrading.mdx
  • apps/www/src/content/docs/components/alert-dialog/props.ts
  • apps/www/src/content/docs/components/avatar/props.ts
  • apps/www/src/content/docs/components/badge/props.ts
  • apps/www/src/content/docs/components/button/props.ts
  • apps/www/src/content/docs/components/callout/props.ts
  • apps/www/src/content/docs/components/chip/props.ts
  • apps/www/src/content/docs/components/combobox/props.ts
  • apps/www/src/content/docs/components/command/props.ts
  • apps/www/src/content/docs/components/context-menu/props.ts
  • apps/www/src/content/docs/components/dialog/props.ts
  • apps/www/src/content/docs/components/drawer/props.ts
  • apps/www/src/content/docs/components/icon-button/props.ts
  • apps/www/src/content/docs/components/image/props.ts
  • apps/www/src/content/docs/components/input/props.ts
  • apps/www/src/content/docs/components/menu/props.ts
  • apps/www/src/content/docs/components/popover/props.ts
  • apps/www/src/content/docs/components/preview-card/props.ts
  • apps/www/src/content/docs/components/select/props.ts
  • apps/www/src/content/docs/components/textarea/props.ts
  • apps/www/src/content/docs/components/tooltip/props.ts
  • apps/www/src/content/docs/components/tour/props.ts
  • apps/www/src/content/docs/theme/overview/demo.ts
  • apps/www/src/content/docs/theme/overview/index.mdx
  • apps/www/src/content/docs/theme/overview/props.ts
  • apps/www/src/content/docs/theme/radius/index.mdx
  • apps/www/src/content/docs/theme/typography/index.mdx
  • apps/www/src/styles/surfaces.css
  • apps/www/src/styles/typeset.css
  • packages/raystack/components/accordion/accordion.module.css
  • 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/breadcrumb/breadcrumb.module.css
  • packages/raystack/components/button/button.module.css
  • packages/raystack/components/button/button.tsx
  • packages/raystack/components/calendar/calendar.module.css
  • packages/raystack/components/callout/callout.module.css
  • packages/raystack/components/callout/callout.tsx
  • packages/raystack/components/chat-panel/chat-panel.module.css
  • packages/raystack/components/chat/chat.module.css
  • packages/raystack/components/checkbox/checkbox.module.css
  • 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/data-view/components/list.tsx
  • packages/raystack/components/data-view/components/timeline.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/indicator/indicator.module.css
  • packages/raystack/components/input/input.module.css
  • packages/raystack/components/input/input.tsx
  • packages/raystack/components/link/link.module.css
  • packages/raystack/components/menu/menu-content.tsx
  • packages/raystack/components/menu/menu.module.css
  • packages/raystack/components/meter/meter.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/progress/progress.module.css
  • packages/raystack/components/prompt-input/prompt-input.module.css
  • packages/raystack/components/radio/radio.module.css
  • packages/raystack/components/select/select-content.tsx
  • packages/raystack/components/select/select.module.css
  • packages/raystack/components/sidebar/sidebar.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-provider/__tests__/data-slots.test.tsx
  • packages/raystack/components/theme-provider/__tests__/theme.test.tsx
  • packages/raystack/components/theme-provider/index.tsx
  • packages/raystack/components/theme-provider/switcher.tsx
  • packages/raystack/components/theme-provider/theme.tsx
  • packages/raystack/components/theme-provider/types.ts
  • packages/raystack/components/theme/__tests__/data-slots.test.tsx
  • packages/raystack/components/theme/__tests__/mocks.ts
  • packages/raystack/components/theme/__tests__/script.test.ts
  • packages/raystack/components/theme/__tests__/ssr.test.tsx
  • packages/raystack/components/theme/__tests__/store.test.ts
  • packages/raystack/components/theme/__tests__/theme.test.tsx
  • packages/raystack/components/theme/context.ts
  • packages/raystack/components/theme/index.tsx
  • packages/raystack/components/theme/portal.ts
  • packages/raystack/components/theme/script.ts
  • packages/raystack/components/theme/settings.ts
  • packages/raystack/components/theme/store.ts
  • packages/raystack/components/theme/switcher.tsx
  • packages/raystack/components/theme/theme.tsx
  • packages/raystack/components/theme/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/hooks/useScaling.tsx
  • packages/raystack/icons/__tests__/registry.test.tsx
  • packages/raystack/index.tsx
  • packages/raystack/package.json
  • packages/raystack/rollup.config.mjs
  • 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.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: 3a223aa

@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/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/__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.
Components whose geometry was pinned in pixels now follow `--rs-scaling`,
and the last few surfaces still on pre-RFC tokens follow the theme.

- Switch derives its track width from the thumb's own tokens, so the thumb
  no longer overflows the track above scaling 1 (0.9 / 1.1 give symmetric
  1.8px / 2.2px insets and 30.6px / 37.4px tracks).
- Tooltip, PreviewCard and Tour arrows paint `--rs-color-panel`, so they
  go translucent with the body under `panelBackground="translucent"`.
  They are the popup surface, not a foreground colour as the RFC assumed.
- Badge, Indicator, FilterChip, Accordion and Calendar multiply their raw
  pixel heights by `--rs-scaling`.
- PromptInput submit, Chat jump button, ChatPanel trigger and the Indicator
  pill move off the legacy `--rs-radius-full` onto the Switch's
  `max(radius-N, --rs-radius-thumb)`, so they square off at `radius="none"`.
  Radio, Spinner and ColorPicker thumbs stay round.
- Icon slots size their own `svg` from the slot token in Input (and so
  Search and Combobox), Tabs, FilterChip and Sidebar, the convention
  Combobox items and the Search clear button already used.
- DataView reads the resolved `--rs-scaling` through a new `useScaling`,
  so the virtualizer estimates, the sticky group offsets and the timeline
  lane geometry zoom with the theme. An explicit px prop is left alone.
- Progress and Meter tracks derive their radius from the theme factor
  instead of a literal 1px.
- Docs: the ThemePreviewSwitcher demo gets a provider, and ThemePanelDemo
  pins `hasBackground` and stretches its vertical Separator.

At scaling 1 with `radius="medium"` every dimension is unchanged from
production. Two intended differences remain: the four pill controls
compute 9999px instead of 800px, matching what Switch and Slider already
do on this branch, and a bare lucide icon in a Tabs slot is now 16x16
rather than overflowing to 16x24.
The panel token family works: `--rs-color-panel` resolves to the 80%
mix and `--rs-panel-backdrop-filter` to `blur(64px)` on the surface and
on the portalled popup. What the demos lacked was anything to be
translucent against.

- The `panelBackground` demo swaps its smooth diagonal gradient for
  hard-edged bands, so the 20% that bleeds through is a real hue shift
  rather than a faint tint, and the blur has an edge to soften. Both
  scopes now sit over the same repeating pattern, so the comparison is
  between the two settings and not between two parts of a gradient. It
  is also tall enough for the popovers to open over the pattern.
- The theme panel's popups opened over `--rs-color-background-base-primary`,
  which is exactly what `--rs-color-panel-translucent` mixes from, so
  `Panel` changed nothing visible. They now open over a band.
The panel was a flat 80% mix of the surface in both appearances. Radix
uses two different treatments, and the dark one is the reason ours read
as solid: mixing a dark panel with the dark page behind it changes
almost nothing, however much of it you take away.

- Light: 70% of the panel surface, Radix's `rgba(255, 255, 255, 0.7)`.
- Dark: `--rs-color-overlay-base-a1`, a faint white lift over the blur,
  standing in for Radix's `--gray-a2` (`#ffffff09`). The panel is
  carried by `blur(64px)` rather than by its own colour.

`--rs-color-panel-translucent` now mixes from `--rs-color-panel-solid`
rather than restating the token it is defined from.
The docs shell ran on `next-themes` plus the legacy `ThemeProvider`,
which put the tokens on `<html>`. Every `ThemePreview` demo on the site
therefore had no parent theme and resolved `appearance: "system"` against
the OS, so the demos stayed light with the docs in dark. They now
inherit the page.

The theme element is a real element, so what used to sit on `:root` and
on `body` moves onto it:

- `--docs-*` in `typeset.css` and `surfaces.css` are declared on
  `.rs-theme`. Left on `:root` they would reference `--rs-*` tokens that
  are no longer defined there and compute to guaranteed-invalid.
- `body` stops painting the ground; the theme element paints it and
  covers the viewport, and `color-scheme` covers the overscroll area.
- The logo's dark rule keys off any ancestor rather than `html`.

`ThemePreview`'s inline script replaces `next-themes` for pre-hydration
appearance, so the dependency is dropped. The header toggle reads
`useThemePreview`.

`Theme` and `ThemePreview` cannot both own the root, so the
`ThemeSwitcher` examples on the legacy theme page no longer drive the
site; the callout there now says so.
The theming section carried two pages: an Overview documenting `Theme`,
and a ThemePreview page. `Theme` is going away, so there is one page.

- `theme/overview` is now the `ThemePreview` documentation, with the
  token naming and category index kept from the old overview and the
  rest of it dropped: the installation, customization and framework
  sections restated the component's own reference, and the 200-line
  scoped-theming chapter documented nesting rules for a component that
  will not ship.
- `theme/preview` is removed; its demos and prop tables move with it.
- The migration table moves to Upgrading, which is where the breaking
  changes for a release already live, as a 2.0 section.
- Getting Started, Introduction and Styling mounted `Theme` and read
  `useTheme`; they now mount `ThemePreview` and read `useThemePreview`.
  Styling's attribute table gains the four settings the old one had no
  equivalent for, and drops `data-style`.

The panel background section now says what the setting does in each
appearance, and that a translucent panel over a plain page is
indistinguishable from a solid one.
The striped backdrop the two panel demos sat on was loud enough to
read as a warning label. Both now use a soft mesh of three radial
gradients over the accent, which still gives the 30% that bleeds
through a real hue shift and the blur an edge to soften.

The playground gains Menu, Select, Dialog and Drawer next to Tooltip
and Popover, so all six overlay surfaces the `Panel` setting reaches
can be opened from one place. Menu and Select land on the backdrop;
Dialog and Drawer read against their own scrim.
Neither demo paints a background of its own now. The panel demo puts a
popup over its own paragraph instead, which is what an overlay actually
covers, and the playground's six overlay triggers sit in an ordinary row
with the rest of the sampler.

The effect is quiet in light, where the panel keeps 70% of a surface the
page is already painted in, and plain in dark, where the panel is a faint
lift and the text behind reads straight through it. The section says as
much, so the demo does not have to stage a backdrop to make the point.
Radix Themes uses `blur(64px)`, which flattens anything behind a popup
into one tone. 40px keeps enough of the shape of what it covers for the
panel to read as glass rather than as a tinted sheet.
Toggling light/dark arrived in four waves. Measured on /docs/theme/overview,
4393 visible elements: 26 faded over 400ms (every `.rs-theme`, from
appearance.css), 118 over 200ms, 3 over 150ms, and 4236 had no colour
transition at all and snapped on the next frame. Text popping against a ground
still fading is the jank.

Giving every element the same transition does not work: a `*` rule covering
background-color/border-color/color/etc. starts ~10k concurrent transitions on
that page and drops it to roughly 5fps — three rendered frames for a 200ms
fade, worse than no animation. `color` alone accounts for ~8900 of them,
because it inherits, so every span picks one up.

So the switch crossfades one snapshot of the page instead. `setValue` wraps the
appearance change in `document.startViewTransition` and marks `<html>` for its
duration; theme.css gives that crossfade `--rs-duration-normal` and
`--rs-ease-out`, the same pair the components use. Cost is independent of DOM
size — measured identical frame timing to no crossfade at all (median 17ms).
The marker scopes the rules so an app's own view transitions keep their timing,
and `flushSync` puts the new attributes in the DOM while the snapshot is being
captured.

Falls back to applying the change directly where view transitions are
unsupported or reduced motion is on; `disableTransitionOnChange` still
suppresses everything.

Also brings the stragglers onto `--rs-duration-normal`, so the same kind of
state change takes the same time everywhere: the theme element itself
(400ms/ease), Checkbox and Radio surfaces, Toggle's content and group,
Breadcrumb and Link hover colour, and Chat's jump button, which said it
mirrored Button but ran 50ms quicker. `--rs-duration-press` stays for `:active`
feedback and `--rs-duration-fast` for popup entrances, which is what it is for.

The docs root drops `disableTransitionOnChange` so the site shows the crossfade.
Dialog, AlertDialog and Drawer rendered with no backdrop. `.dialogOverlay`
paints `var(--rs-color-overlay)`, which `theme.css` declares only under
`[data-theme]`; the backdrop is a sibling of the popup inside the portal, and
only the popup carried the injected theme. Outside any `[data-theme]` ancestor
the variable is guaranteed-invalid, so `background-color` went unset and the
scrim was transparent.

It survived on main because the legacy `Theme` writes `data-theme` on `<html>`,
which portals inherit. Moving the site onto `ThemePreview` put the tokens on an
element inside `<body>`, and everything portalled past it lost them.

A Base UI portal renders its own `<div>` under `<body>` — `FloatingPortal`
extends `BaseUIComponentProps<'div'>` — and one node per instance, so spreading
the theme there covers every portalled part at once, backdrop and viewport and
positioner included, without scopes colliding: two open portals from different
`ThemePreview` scopes each keep their own `data-theme`. The popup keeps its own
injection because `Select`'s popup is not always portalled.

Verified on the docs: the backdrop resolves `lab(0 0 0 / 0.3)` again, and a
dialog opened from a light scope inside a dark page stays light.
`LiveProvider` parses a snippet as a single expression unless `noInline` is
set, so a demo that declares a helper component before its JSX failed with
`SyntaxError: Unexpected token (2:1)`. `Preview` renders no `LiveError`, so
those demos went blank with nothing in the console.

Two demos on the Icons page were affected: the `components` override and the
nesting example, both of which define a stand-in icon before rendering.

`noInline` is keyed off the `render(` call the mode requires rather than set
globally, because the two modes are mutually exclusive — a bare expression,
which every other demo in the repo is, renders nothing under `noInline`.
…ew to Theme

RFC 004's replacement has been living beside the old provider as
`ThemePreview`. Take the name over and delete what it replaced, so there is
one theme to maintain rather than two.

Removed:

- `components/theme-provider/` — `Theme`, the deprecated `ThemeProvider`
  alias, `ThemeSwitcher`, `useTheme` and `ThemeProviderProps`.
- The `data-style="modern|traditional"` stylesheets that existed only for it:
  `styles/radius.css`, `styles/fonts-legacy.css`, the font-pair block in
  `typography.css`, and the `--rs-font-lora` / `--rs-font-josefin-sans`
  tokens. Nothing set `data-style` any more, so this is inert at runtime.

Renamed, as a move so history follows: `components/theme-preview/` becomes
`components/theme/`, `ThemePreview` becomes `Theme`, `ThemePreviewSwitcher`
becomes `ThemeSwitcher`, `useThemePreview` becomes `useTheme`, and the
`theme-preview*` data-slots become `theme*`.

`icons` is carried over. It was the one capability the old provider had with
no equivalent in the new one, and the whole Icons page is written around
`<Theme icons>`, so `Theme` takes an `IconOptions` and mounts `IconProvider`
only when configured, exactly as before. Its five wiring tests move across
with it. Everything else already had an equivalent; `themes`, `attribute`,
the name-to-attribute `value` map and `style="traditional"` are the
deliberate RFC removals.

Docs follow throughout, including the upgrade guide's 2.0 section, which now
reads as a rewrite of `Theme` rather than a replacement for it, and the
radius and typography pages, whose `data-style` sections are replaced by the
`radius` and `scaling` settings and the font CSS variables.
`startViewTransition` snapshots the whole document, so a nested scope
switching appearance froze the entire page around what is only an inset
repaint. Reserve the crossfade for the root, which does repaint the page.

Also run the controlled-scope docs demo, which declared a helper and so
needed an explicit `render` call under noInline.
…ports

Radius was a cva variant in nine components and a bespoke `radiusClass`
helper in twelve portalled surfaces. Replace the helper with `radiusStyle`,
a cva built from the same `radiusVariants` fragment, so every consumer goes
through cva. Drawer already had its own cva and was routing radius around
it through the className escape hatch; it now spreads the variant properly.

Trim the theme barrel to what a consumer can actually use. THEME_DEFAULT_-
SETTINGS and THEME_SETTING_KEYS had no consumer outside the package, and
THEME_SETTING_VALUES had exactly one — the docs' theme panel, which now
spells out its own list under a `satisfies` constraint. ThemeContextValue
was unreachable (nothing public returns one) and ThemeSettingKey and
ThemeRenderProp are derivable from types that remain exported. All six
still exist internally.

Document the radius prop across the docs props tables. It was on 4 of 21
components, and two of those were wrong: Avatar claimed `small | full`
with a "small" default when it has no default at all, and Image omitted
`large`. Adds or corrects the entry in 23 interfaces, including the two
submenu content types that forward their parent's props.

BREAKING CHANGE: `radiusClass` is replaced by `radiusStyle`, which takes
an options object and returns `''` rather than `undefined` when unset.
THEME_DEFAULT_SETTINGS, THEME_SETTING_KEYS, THEME_SETTING_VALUES and the
ThemeContextValue, ThemeSettingKey and ThemeRenderProp types are no longer
exported.
Nothing in it is data-view specific: it takes any element ref, reads the
resolved `--rs-scaling` off computed style, and re-reads when a theme
anywhere above changes `data-scaling`. Only the doc comment named the two
renderers that happen to use it.

Left out of `hooks/index.tsx` on purpose. That barrel is the published
`@raystack/apsara/hooks` subpath, so adding it there would make an
internal helper public API; consumers of it import by path instead.
…d script

`rollup-plugin-postcss` emits the extracted stylesheet through `emitFile`
in its own `generateBundle`, so a plugin ordered after it can read that
asset from the bundle and emit a sibling. That replaces the separate node
script chained onto the build with `&&`: one process, one failure mode,
and `rollup -c` on its own can no longer skip the step.

The script's `existsSync` check becomes a `closeBundle` assertion, since
`generateBundle` returns early when the asset is absent — without it, an
entry that quietly stopped extracting CSS would produce nothing instead
of failing. The "no font imports found" guard is carried over as-is: a
silent miss would publish a `no-fonts` sheet that still calls out to
Google Fonts, so it errors the build.
Drop the ones that restate the code they sit on, and shorten the rest to
the reason the code is the way it is.

Removed: the decorative section banners in the theme tests, which repeated
the describe() below them; CSS comments that named the property they sat
on; a note in timeline.tsx explaining a bit-shift style choice it then
admitted was not load-bearing; a dep-array comment duplicating the
biome-ignore two lines above it.

Condensed: the long narrative blocks, chiefly in timeline.tsx (427 comment
lines to 268), theme.tsx, theme.css, useScaling, portal.ts and the rollup
font-free CSS plugin.

Kept: public JSDoc on props and exported types, since that is the
documented surface, and every comment carrying a reason a reader could not
recover from the code, browser and jsdom quirks included.

No code changes; 2779 tests still pass.
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