Skip to content

feat(theme): revamped Theme - #893

Open
rohanchkrabrty wants to merge 1 commit into
mainfrom
worktree-theme-revamp
Open

feat(theme): revamped Theme#893
rohanchkrabrty wants to merge 1 commit into
mainfrom
worktree-theme-revamp

Conversation

@rohanchkrabrty

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 and a container prop 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, reorders --rs-font-mono so JetBrains Mono precedes Menlo, 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 2, 2026 8:32pm UTC

@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Too many files!

This PR contains 113 files, which is 13 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: Team

Run ID: 6a004e5c-c146-4ece-ab6a-e9846e8380f3

📥 Commits

Reviewing files that changed from the base of the PR and between fe977c2 and bc2b4e8.

📒 Files selected for processing (113)
  • 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/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-table/data-table.module.css
  • packages/raystack/components/data-view/data-view.module.css
  • 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/empty-state/empty-state.module.css
  • packages/raystack/components/field/field.module.css
  • packages/raystack/components/filter-chip/filter-chip.module.css
  • packages/raystack/components/floating-actions/floating-actions.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/kbd/kbd.module.css
  • packages/raystack/components/link/link.module.css
  • packages/raystack/components/menu/cell.module.css
  • packages/raystack/components/menu/menu-content.tsx
  • packages/raystack/components/menu/menu.module.css
  • packages/raystack/components/menubar/menubar.module.css
  • packages/raystack/components/message/message.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/prompt-input/prompt-input.module.css
  • packages/raystack/components/reasoning/reasoning.module.css
  • packages/raystack/components/scroll-area/scroll-area.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/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/radius.module.css
  • packages/raystack/components/theme-preview/radius.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/toolbar/toolbar.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/index.tsx
  • packages/raystack/package.json
  • packages/raystack/scripts/build-no-fonts-css.js
  • 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: bc2b4e8

@rohanchkrabrty rohanchkrabrty changed the title feat(theme): element-mounted ThemePreview with radius and scaling (RFC 004) feat(theme): revamped Theme Sep 3, 2026

const entry: StoredEntry = { v: STORAGE_VERSION, settings };
try {
window.localStorage.setItem(persistKey, JSON.stringify(entry));

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.

Theme changes get dropped when localStorage is unavailable.

For persisted keys, storage is the source of truth. readRaw (L41) and this setItem both catch-and-return, so the write is lost, notifyThemeStorage() on L115 still fires, readers re-read the previous value, and the setting doesn't move.

Reproduced: with persistKey set and a throwing localStorage, appearance stays light on click; without persistKey it toggles fine. That covers Safari private mode, sandboxed iframes, and browsers with site data blocked — and the more correct config (with persistKey) is the affected one.

Could writeStoredSettings return whether it succeeded, so the caller can fall back to in-memory state?

if (isSettingsEmpty(patch)) return;

const persisted = persistedKeysRef.current;
if (persistKey && persisted.length > 0) {

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.

Companion to the store.ts:111 comment — this is where the fallback would live. When the write fails there's no path to setLocal, since the loop below skips anything in persisted.

Something like if (!writeStoredSettings(...)) { /* mirror the patch into local */ } would keep the switcher working when storage is unavailable.

`if(o&&typeof o==="object"&&typeof o.v==="number"&&o.v<=${STORAGE_VERSION}` +
`&&o.settings&&typeof o.settings==="object")s=o.settings}}catch(t){}` +
`for(var i=0;i<m.length;i++){` +
`var k=m[i][0],a=m[i][1],v=s[k];` +

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.

v is sourced only from storage, so an unstored key is skipped at L72 and the server's data-theme="light" survives to first paint. Since DEFAULT_SETTINGS.appearance is system, that's the first visit for most users.

Verified: renderToStaticMarkup(<ThemePreview persistKey="app">) with OS dark and empty storage emits data-theme="light", script present but with nothing to act on.

Passing the effective settings into createThemeScript and falling back — var v = (k in s) ? s[k] : d[k] — would let system resolve through matchMedia on a first visit. Worth also emitting the script when appearance is uncontrolled system even without a persistKey.

}
:where([data-radius="full"]) {
--rs-radius-factor: 1.5;
--rs-radius-pill: 9999px;

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.

--rs-radius-pill: 9999px feeds 89 max(var(--rs-radius-pill, 0px), …) sites, which includes every large panel.

Measured in Chromium against the built style.css: a dialog inside data-radius="full" computes border-radius: 9999px. Browsers clamp radius to half the box, so it renders as a stadium — same for dropdowns, the command palette, code blocks, data tables, sidebars and calendars.

Capping panel surfaces (min(var(--rs-radius-pill), 16px), or a separate --rs-radius-panel-max) while keeping the raw pill for buttons, badges, chips and inputs would fix it.

defaultVariants: {
size: 3,
radius: 'small',
radius: 'medium',

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.

This default means Avatar no longer follows the theme radius. .radius-medium.radius-medium is (0,2,0) and always applied, so .avatar-size-N at (0,1,0) can never take effect.

Measured on a size-8 avatar:

theme radius with the default class without it
full 8px 9999px
large 8px 12px

So "renders exactly as the old default did" holds only at factor 1. Every other component (Button, IconButton, Badge, Callout, Chip, Input, TextArea) sets no radius default and does respond to the theme.

Dropping radius from defaultVariants fixes both — one line. It also makes the 13 max(var(--rs-radius-pill, …)) declarations in avatar.module.css reachable again.

try {
parsed = JSON.parse(raw);
} catch {
// Includes the legacy `"dark"` bare theme name, which is not an object.

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.

This comment is the only place the legacy format is acknowledged, and it's handled safely — but the user's saved appearance is silently lost. The old provider stored a bare string (localStorage.setItem('theme', 'dark'), default key theme), JSON.parse throws, and we fall back to defaults.

Either read a bare light / dark / system value once as appearance, or note in the migration guide that a fresh persistKey is recommended.

padding: var(--rs-space-3);
background-color: var(--rs-color-background-base-primary);
background-color: var(--rs-color-panel);
backdrop-filter: var(--rs-panel-backdrop-filter);

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.

Separate from the -webkit- point: Drawer.Content's new radius prop has no base to work from — this file declares neither border-radius nor --rs-radius-step.

Measured: radius="medium" gives a flat 4px on all four corners of a full-height sheet, including the two flush with the viewport; bare gives 0px. Drawers also don't pick up the theme radius at all.

Per-side radii would solve it, or the prop could be left off Drawer.

rendered on macOS. */
--rs-font-mono:
var(--rs-font-menlo), var(--rs-font-jetbrains-mono), monospace;
var(--rs-font-jetbrains-mono), var(--rs-font-menlo), monospace;

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.

This reorder is the right fix, but it changes all monospace text on macOS for existing Theme consumers too. It's covered on the new ThemePreview page — worth a line in the release notes as well, since Theme users won't have a reason to read that page.

assignSetting(changed, key, settings[key]);
}
}
if (!isSettingsEmpty(changed))

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.

onValueChange fires without a user action on the hydration path.

The useSyncExternalStore correction from the server snapshot to the stored value looks identical to a real change here, so consumers get a phantom event on every SSR page load where storage differs from the seed. Anything with a side effect — analytics, a server write, a toast — will fire spuriously.

Verified: CSR gives 0 calls; hydration gives 1, {"accentColor":"mint"}.

Gating the first post-hydration reconciliation (e.g. skip while previousSettings.current is still the server-derived value) would keep the callback meaning "the user changed something".

);
expect(readStoredSettings('app')).toEqual({ radius: 'large' });
});

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.

The version guard discards a newer-schema entry instead of leaving it alone, so a write from an older build destroys settings a newer build owns.

Verified: with {v: 99, settings: {appearance: 'dark', radius: 'full'}} stored, writeStoredSettings('key', ['accentColor'], {accentColor: 'mint'}) leaves {v: 1, settings: {accentColor: 'mint'}}.

Not reachable while STORAGE_VERSION is 1, so no rush — but the branch is here to handle exactly this case and currently handles it destructively. Bailing out of the write when the stored v is higher would be safer.

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.

2 participants