feat(theme): revamped Theme - #893
rohanchkrabrty wants to merge 23 commits into
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedToo 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 configurationConfiguration used: Organization UI Review profile: CHILL Plan: Advanced Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (164)
You can disable this status message by setting the 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. Comment |
commit: |
|
|
Separate from the bug list, one API question we should settle now rather than after release. The seven settings arrive as one Where the ecosystem sits — the split tracks whether the settings are a closed set of enums or an open tree of tokens:
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 Not full flat though, because the two props get used differently.
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 One thing to be clear about before it comes up: this does not let us delete What it costs: seven new prop names, and We shouldn't add per-key callbacks. The callback fix in the other comment matters more than this one. Without it, flattening just makes a broken contract easier to see. |
@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 Let's keep the existing approach. Will address the discoverability by improving the docs and the callback bug |
|
1. 2. 3. Imports from 4. Transition in 5. Exports - Dropped 6. 7. |
- 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.
5c76d5e to
3a223aa
Compare
Summary
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.--rs-*declaration is wrapped in:where()so a consumer rule on the stable.rs-themeclass 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 hardcodeddata-stylescales, and panel/overlay/backdrop-filter tokens.useSyncExternalStorepersistence gated onpersistKey, plus a small pre-hydration script that patches its own parent and a:has()colour-scheme rule that writes nothing to<html>.radiuscva variant and CSS module;ImageandAvatarmigrate onto the five-value scale.style-no-fonts.cssalongsidestyle.css, and adds a theme playground panel plus a docs page with a migration guide.Themeships unchanged, so this is additive — no consumer migration is forced by this PR.