From bc2b4e8ba505bdbb2f62eb4c20fe2fd121df64a6 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 3 Sep 2026 01:55:39 +0530 Subject: [PATCH 01/22] feat(theme): element-mounted ThemePreview with radius, scaling and panel tokens (RFC 004) Adds `ThemePreview`, an element-mounted theme alongside the existing `Theme`, implementing RFC 004. Tokens move off `` 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 ``, 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) Claude-Session: https://claude.ai/code/session_017vcxAxsBpKAvtUfDi2wFKr --- apps/www/src/components/demo/demo.tsx | 2 + apps/www/src/components/theme-panel-demo.tsx | 150 ++++ apps/www/src/content/docs/theme/meta.json | 1 + .../src/content/docs/theme/overview/index.mdx | 8 + .../src/content/docs/theme/preview/demo.ts | 107 +++ .../src/content/docs/theme/preview/index.mdx | 430 +++++++++++ .../src/content/docs/theme/preview/props.ts | 154 ++++ .../alert-dialog/alert-dialog-content.tsx | 18 +- .../announcement-bar.module.css | 2 +- .../avatar/__tests__/avatar.test.tsx | 18 +- .../components/avatar/avatar.module.css | 44 +- .../raystack/components/avatar/avatar.tsx | 10 +- .../components/badge/badge.module.css | 3 +- packages/raystack/components/badge/badge.tsx | 5 +- .../breadcrumb/breadcrumb.module.css | 6 +- .../components/button/button.module.css | 3 +- .../raystack/components/button/button.tsx | 5 +- .../components/calendar/calendar.module.css | 6 +- .../components/callout/callout.module.css | 3 +- .../raystack/components/callout/callout.tsx | 11 +- .../chat-panel/chat-panel.module.css | 2 +- .../raystack/components/chat/chat.module.css | 4 +- .../components/checkbox/checkbox.module.css | 2 +- .../raystack/components/chip/chip.module.css | 5 +- packages/raystack/components/chip/chip.tsx | 6 +- .../code-block/code-block.module.css | 4 +- .../color-picker/color-picker.module.css | 4 +- .../components/combobox/combobox-content.tsx | 26 +- .../components/combobox/combobox.module.css | 8 +- .../components/command/command-dialog.tsx | 23 +- .../components/command/command.module.css | 11 +- .../context-menu/context-menu-content.tsx | 19 +- .../data-table/data-table.module.css | 4 +- .../components/data-view/data-view.module.css | 4 +- .../components/dialog/dialog-content.tsx | 18 +- .../components/dialog/dialog.module.css | 8 +- .../components/drawer/drawer-content.tsx | 21 +- .../components/drawer/drawer.module.css | 5 +- .../components/editor/editor.module.css | 4 +- .../empty-state/empty-state.module.css | 2 +- .../components/field/field.module.css | 2 +- .../filter-chip/filter-chip.module.css | 2 +- .../floating-actions.module.css | 2 +- .../icon-button/icon-button.module.css | 3 +- .../components/icon-button/icon-button.tsx | 5 +- .../components/image/__tests__/image.test.tsx | 7 +- .../components/image/image.module.css | 19 +- packages/raystack/components/image/image.tsx | 9 +- .../components/input/input.module.css | 7 +- packages/raystack/components/input/input.tsx | 5 +- .../raystack/components/kbd/kbd.module.css | 2 +- .../raystack/components/link/link.module.css | 2 +- .../raystack/components/menu/cell.module.css | 2 +- .../raystack/components/menu/menu-content.tsx | 19 +- .../raystack/components/menu/menu.module.css | 6 +- .../components/menubar/menubar.module.css | 2 +- .../components/message/message.module.css | 2 +- .../components/otp-field/otp-field.module.css | 2 +- .../components/popover/popover.module.css | 6 +- .../raystack/components/popover/popover.tsx | 26 +- .../preview-card/preview-card.module.css | 6 +- .../components/preview-card/preview-card.tsx | 23 +- .../prompt-input/prompt-input.module.css | 2 +- .../components/reasoning/reasoning.module.css | 2 +- .../scroll-area/scroll-area.module.css | 2 +- .../components/select/select-content.tsx | 35 +- .../components/select/select.module.css | 10 +- .../components/sidebar/sidebar.module.css | 6 +- .../components/slider/slider.module.css | 6 +- .../raystack/components/tabs/tabs.module.css | 6 +- .../components/text-area/text-area.module.css | 3 +- .../components/text-area/text-area.tsx | 5 +- .../__tests__/data-slots.test.tsx | 57 ++ .../theme-preview/__tests__/mocks.ts | 87 +++ .../theme-preview/__tests__/script.test.ts | 185 +++++ .../theme-preview/__tests__/ssr.test.tsx | 171 +++++ .../theme-preview/__tests__/store.test.ts | 160 ++++ .../__tests__/theme-preview.test.tsx | 721 ++++++++++++++++++ .../components/theme-preview/context.ts | 61 ++ .../components/theme-preview/index.tsx | 46 ++ .../components/theme-preview/portal.ts | 44 ++ .../theme-preview/radius.module.css | 33 + .../components/theme-preview/radius.ts | 30 + .../components/theme-preview/script.ts | 81 ++ .../components/theme-preview/settings.ts | 176 +++++ .../components/theme-preview/store.ts | 138 ++++ .../components/theme-preview/switcher.tsx | 41 + .../theme-preview/theme-preview.tsx | 487 ++++++++++++ .../theme-preview/use-system-appearance.ts | 31 + .../components/toast/toast-provider.tsx | 17 +- .../components/toast/toast.module.css | 6 +- .../components/toggle/toggle.module.css | 6 +- .../components/toolbar/toolbar.module.css | 2 +- .../components/tooltip/tooltip-content.tsx | 23 +- .../components/tooltip/tooltip.module.css | 6 +- .../raystack/components/tour/tour-content.tsx | 23 +- .../raystack/components/tour/tour.module.css | 6 +- packages/raystack/index.tsx | 38 + packages/raystack/package.json | 3 +- .../raystack/scripts/build-no-fonts-css.js | 36 + packages/raystack/styles/colors.css | 5 +- packages/raystack/styles/effects.css | 7 +- packages/raystack/styles/fonts-legacy.css | 4 + packages/raystack/styles/fonts.css | 4 + packages/raystack/styles/index.css | 8 + .../raystack/styles/primitives/accent.css | 17 +- .../raystack/styles/primitives/appearance.css | 4 +- packages/raystack/styles/primitives/gray.css | 25 +- .../raystack/styles/primitives/z-index.css | 4 +- packages/raystack/styles/radius.css | 39 +- packages/raystack/styles/spacing.css | 46 +- packages/raystack/styles/theme.css | 182 +++++ packages/raystack/styles/typography.css | 79 +- 113 files changed, 4272 insertions(+), 269 deletions(-) create mode 100644 apps/www/src/components/theme-panel-demo.tsx create mode 100644 apps/www/src/content/docs/theme/preview/demo.ts create mode 100644 apps/www/src/content/docs/theme/preview/index.mdx create mode 100644 apps/www/src/content/docs/theme/preview/props.ts create mode 100644 packages/raystack/components/theme-preview/__tests__/data-slots.test.tsx create mode 100644 packages/raystack/components/theme-preview/__tests__/mocks.ts create mode 100644 packages/raystack/components/theme-preview/__tests__/script.test.ts create mode 100644 packages/raystack/components/theme-preview/__tests__/ssr.test.tsx create mode 100644 packages/raystack/components/theme-preview/__tests__/store.test.ts create mode 100644 packages/raystack/components/theme-preview/__tests__/theme-preview.test.tsx create mode 100644 packages/raystack/components/theme-preview/context.ts create mode 100644 packages/raystack/components/theme-preview/index.tsx create mode 100644 packages/raystack/components/theme-preview/portal.ts create mode 100644 packages/raystack/components/theme-preview/radius.module.css create mode 100644 packages/raystack/components/theme-preview/radius.ts create mode 100644 packages/raystack/components/theme-preview/script.ts create mode 100644 packages/raystack/components/theme-preview/settings.ts create mode 100644 packages/raystack/components/theme-preview/store.ts create mode 100644 packages/raystack/components/theme-preview/switcher.tsx create mode 100644 packages/raystack/components/theme-preview/theme-preview.tsx create mode 100644 packages/raystack/components/theme-preview/use-system-appearance.ts create mode 100644 packages/raystack/scripts/build-no-fonts-css.js create mode 100644 packages/raystack/styles/fonts-legacy.css create mode 100644 packages/raystack/styles/fonts.css create mode 100644 packages/raystack/styles/theme.css diff --git a/apps/www/src/components/demo/demo.tsx b/apps/www/src/components/demo/demo.tsx index ba98b172d..4a2c8c123 100644 --- a/apps/www/src/components/demo/demo.tsx +++ b/apps/www/src/components/demo/demo.tsx @@ -54,6 +54,7 @@ import { } from '../dataview-demo'; import LinearMenuDemo from '../linear-menu-demo'; import PopoverColorPicker from '../popover-color-picker'; +import ThemePanelDemo from '../theme-panel-demo'; import TourDemo from '../tour-demo'; import DemoPlayground from './demo-playground'; import DemoPreview from './demo-preview'; @@ -88,6 +89,7 @@ export default function Demo(props: DemoProps) { ChipInputDemo, LinearMenuDemo, PopoverColorPicker, + ThemePanelDemo, TourDemo, NextLink, AlignCenter, diff --git a/apps/www/src/components/theme-panel-demo.tsx b/apps/www/src/components/theme-panel-demo.tsx new file mode 100644 index 000000000..d7b1e05de --- /dev/null +++ b/apps/www/src/components/theme-panel-demo.tsx @@ -0,0 +1,150 @@ +'use client'; + +import { + ACCENT_COLORS, + Avatar, + Badge, + Button, + Callout, + Checkbox, + Chip, + Flex, + GRAY_COLOR_VALUES, + Input, + PANEL_BACKGROUNDS, + Popover, + Progress, + RADII, + SCALINGS, + Select, + Separator, + Switch, + Text, + ThemePreview, + type ThemeSettings, + Tooltip, + useThemePreview +} from '@raystack/apsara'; +import { useState } from 'react'; + +const APPEARANCES = ['light', 'dark', 'system'] as const; + +/** A live control for every setting, next to a sampler of components. */ +function Controls() { + const { value, resolved, setValue } = useThemePreview(); + + const field = ( + label: string, + key: K, + options: readonly string[] + ) => ( + + + {label} + + + + ); + + return ( + + {field('Appearance', 'appearance', APPEARANCES)} + {field('Accent', 'accentColor', ACCENT_COLORS)} + {field('Gray', 'grayColor', GRAY_COLOR_VALUES)} + {field('Radius', 'radius', RADII)} + {field('Scaling', 'scaling', SCALINGS)} + {field('Panel', 'panelBackground', PANEL_BACKGROUNDS)} + {field('Reduced motion', 'reducedMotion', ['system', 'true', 'false'])} + + + + Resolved: {resolved.appearance} · {resolved.grayColor} + + + ); +} + +function Sampler() { + const [checked, setChecked] = useState(true); + + return ( + + + + + + + + + + Badge + Chip + + + + + + + + Callouts follow the accent and the radius factor. + + + + Tooltip} + /> + Portalled, and still themed + + + + Popover} + /> + + + Theme values cross the portal through context, so this popup + matches the scope it was opened from. + + + + + + ); +} + +/** `isRoot={false}`: one example on a page, not the page itself. */ +export default function ThemePanelDemo() { + return ( + + + + + + + + ); +} diff --git a/apps/www/src/content/docs/theme/meta.json b/apps/www/src/content/docs/theme/meta.json index 47be1c910..067f5f840 100644 --- a/apps/www/src/content/docs/theme/meta.json +++ b/apps/www/src/content/docs/theme/meta.json @@ -2,6 +2,7 @@ "title": "Theme", "pages": [ "overview", + "preview", "colors", "typography", "spacing", diff --git a/apps/www/src/content/docs/theme/overview/index.mdx b/apps/www/src/content/docs/theme/overview/index.mdx index 1bf92cce7..ed79bae52 100644 --- a/apps/www/src/content/docs/theme/overview/index.mdx +++ b/apps/www/src/content/docs/theme/overview/index.mdx @@ -7,6 +7,14 @@ import { switcherDemo, switcherSizeDemo } from "./demo.ts"; Apsara provides a theming system built on CSS custom properties (tokens). Tokens are semantic variables that automatically resolve to appropriate values based on the active theme—so your UI adapts seamlessly when users switch between light and dark modes or when you change accent colors, without any code changes. + + This page documents the original `Theme` component, which continues to ship + unchanged. New applications should use + [`ThemePreview`](/docs/theme/preview) — it server-renders, allows more than + one provider per page, works inside portals, and adds radius, scaling, panel + background and reduced-motion settings. It includes a migration guide. + + ## Installation Wrap your application with the `Theme` component: diff --git a/apps/www/src/content/docs/theme/preview/demo.ts b/apps/www/src/content/docs/theme/preview/demo.ts new file mode 100644 index 000000000..c9440480f --- /dev/null +++ b/apps/www/src/content/docs/theme/preview/demo.ts @@ -0,0 +1,107 @@ +'use client'; + +export const scopeDemo = { + type: 'code', + code: ` + + + + Light scope + + + + + + + Dark scope + + + + ` +}; + +export const accentDemo = { + type: 'code', + code: ` + + {["indigo", "orange", "mint"].map(accent => ( + + + {accent} + + Badge + + + ))} + ` +}; + +export const radiusDemo = { + type: 'code', + code: ` + + {["none", "small", "medium", "large", "full"].map(radius => ( + + + {radius} + + + + ))} + ` +}; + +export const scalingDemo = { + type: 'code', + code: ` + + {["0.9", "1", "1.1"].map(scaling => ( + + + {scaling}x + + + + ))} + ` +}; + +export const componentRadiusDemo = { + type: 'code', + code: ` + + + {/* Follows the theme */} + + {/* Overrides it, without compounding */} + + + + + ` +}; + +export const switcherDemo = { + type: 'code', + code: `` +}; + +export const panelDemo = { + type: 'code', + code: `` +}; diff --git a/apps/www/src/content/docs/theme/preview/index.mdx b/apps/www/src/content/docs/theme/preview/index.mdx new file mode 100644 index 000000000..f44c7485d --- /dev/null +++ b/apps/www/src/content/docs/theme/preview/index.mdx @@ -0,0 +1,430 @@ +--- +title: ThemePreview +description: The rewritten theme — tokens on a real element, seven settings, and per-component radius. +--- + +import { + accentDemo, + componentRadiusDemo, + panelDemo, + radiusDemo, + scalingDemo, + scopeDemo, + switcherDemo +} from "./demo.ts"; + +`ThemePreview` is the next `Theme`, shipping alongside the existing one so applications can migrate at their own pace. It mounts every token-bearing attribute on a **real element** instead of ``, which is what makes the root theme, a nested scope and a portal all behave the same way. + +Three long-standing problems close with that change: + +- **The root can be server-rendered.** Attributes on `` came only from an effect or a blocking script, so the server emitted nothing. Every setting is now an ordinary prop that renders on the first byte. +- **More than one provider can exist per page.** Nothing competes for ``, so an embedded widget or a second independent root just works. +- **Scoped themes reach into portals.** Theme values cross a portal through React context and are re-emitted onto the portalled element, so a popover opened inside a dark scope is dark. + + + `ThemePreview` is additive. The existing `Theme`, `useTheme` and `ThemeSwitcher` are unchanged and keep working. Do not nest one inside the other — pick one per application. + + +## Installation + +```tsx +import { ThemePreview } from "@raystack/apsara"; + +export default function App() { + return ( + + + + ); +} +``` + +Tokens live on the element `ThemePreview` renders, so everything that needs them must be inside it. Consumer CSS and hand-rolled portals mounted outside the provider will not resolve `--rs-*`; the `container` prop on every portalling component is the supported way to place portalled content back inside the theme. + +## Theme panel + +Every setting, live, next to a sampler of components. The panel is a scope with +`isRoot={false}`, so it re-themes itself without touching the page. + + + +## Settings + +One settings object describes the theme. Every key is independently seedable, controllable and persistable, and every key becomes a data attribute on the theme element. + +| Setting | Values | Default | Attribute | +|---|---|---|---| +| `appearance` | `light`, `dark`, `system` | `system` | `data-theme` (resolved) | +| `accentColor` | `indigo`, `orange`, `mint` | `indigo` | `data-accent-color` | +| `grayColor` | `gray`, `mauve`, `slate`, `sage`, `auto` | `auto` | `data-gray-color` (resolved) | +| `radius` | `none`, `small`, `medium`, `large`, `full` | `medium` | `data-radius` | +| `scaling` | `0.9`, `0.95`, `1`, `1.05`, `1.1` | `1` | `data-scaling` | +| `panelBackground` | `solid`, `translucent` | `solid` | `data-panel-background` | +| `reducedMotion` | `true`, `false`, `system` | `system` | `data-reduced-motion` | + +`appearance: "system"` resolves against `prefers-color-scheme` and `grayColor: "auto"` pairs a complementary gray to the accent, both before the attribute is written — `data-theme` only ever holds `light` or `dark`. + +Font families are deliberately not a setting. They are customised through CSS variables instead; see [Fonts](#fonts). + +### Appearance + + + +### Accent + + + +### Radius + +Radius is a factor applied to a fixed base scale, so `radius="small"` means the same thing in every configuration. + + + +### Scaling + +Scaling is a **zoom**, not a density control: it multiplies spacing, radius, type and line height together. Border and divider widths, and font weights, do not scale. + + + +## Controlled and uncontrolled + +`defaultValue` seeds uncontrolled keys; a stored user choice may override it. `value` is authoritative: a controlled key always wins, is never persisted and is never written by the inline script. + +Control is **per key**, so a page can drive appearance from a cookie while leaving accent and radius adjustable: + +```tsx + + + +``` + +## Persistence + +Persistence is **off** unless `persistKey` is set. A theme without one holds its settings in memory and emits no inline script, so a nested scope, an embedded widget and a second independent root all keep their own state by default and cannot collide. + +```tsx +// Persist everything under one namespace + + +// Persist only the appearance; the rest stays in memory + +``` + +A namespace is one `localStorage` entry holding one JSON object alongside a schema version. A write **merges**: it applies only the settings its `persist` covers and leaves every other field intact, including fields owned by a theme with a different `persist` on the same namespace. A missing or unparseable entry falls back to the seeded defaults and is overwritten on the next write; a field outside its union is discarded individually. + +Sharing a `persistKey` is a supported feature, not a hazard — it is exactly what a documentation page wants when several live examples should switch appearance together. Themes on one namespace stay in step within a document, and across tabs through the `storage` event. + +## Server rendering + +Accent, gray, radius, scaling and panel background are ordinary props, so React server-renders them correctly on the first byte. Only appearance can differ between the server and the browser, and only when it is uncontrolled and persisted — which is the one case the inline script covers. + +```tsx +// Next.js App Router: app/layout.tsx +import { ThemePreview } from "@raystack/apsara"; + +export default function RootLayout({ children }) { + return ( + + + {children} + + + ); +} +``` + +No `suppressHydrationWarning` on `` is needed: nothing is written there. The theme element carries it itself, which suppresses attribute diffs exactly one level deep. + +The script renders as the theme element's first child and patches its own parent. It is emitted only for a theme with a `persistKey`, and then only for that namespace's uncontrolled settings — a consumer reading appearance from a cookie ships no script at all. Pass `nonce` if your CSP requires one. + +## Scoping + +A nested `ThemePreview` inherits every key it does not set, so "inherit" is expressed by omission rather than by a value. + +```tsx + + {/* Only the accent changes; appearance, radius and scaling inherit */} + + + + +``` + +### hasBackground + +The component cannot infer whether it should paint, because re-tinting the accent and flipping a panel to dark use the same component but want opposite behaviour. `hasBackground` decides, and its default follows a heuristic: true at the root, true for a nested theme that sets an explicit `light` or `dark` appearance, false for one that only changes accent, gray, radius or scaling. + +Pass `hasBackground={false}` if your application paints its own page background. Foreground colour applies regardless. + +### isRoot + +Exactly one theme per document may own the page's colour scheme, which is what the browser paints in the overscroll area, the document scrollbar, the region below short content, and native widget defaults. That theme carries a `data-rs-root` marker, and `` derives `color-scheme` from it with `:has()` — no JavaScript, nothing written to ``. + +A theme claims the marker when it finds no ancestor theme. A theme that has no ancestor but does **not** own the page — an embedded widget, a micro-frontend — must pass `isRoot={false}`. Everything else about it is unchanged: it still carries `data-theme`, so its own subtree still gets a `color-scheme`; only the three document-level surfaces defer to the host. + +If two elements carry the marker with conflicting appearances, neither wins by position — both rules match `:root` at equal specificity, so `dark` wins because it is declared later. Set `isRoot={false}` on the one that does not own the page. + +### render + +`render` is the `asChild`-style escape hatch: it merges the theme's attributes onto an element you supply instead of adding a wrapper node. + +```tsx +}> + + +``` + +## The useThemePreview hook + +```tsx +import { useThemePreview } from "@raystack/apsara"; + +function AppearanceToggle() { + const { resolved, setValue } = useThemePreview(); + const isDark = resolved.appearance === "dark"; + + return ( + + ); +} +``` + + + +`value` is the settings as set, `system` and `auto` included; `resolved` is the settings as applied. Branch on `resolved`, not `value` — that is what the viewer is actually looking at. + +`root` is the same shape bound to the root provider, so a control inside a scope can flip the page theme: + +```tsx +function PageToggle() { + const { root } = useThemePreview(); + return ( + + ); +} +``` + +The hook **throws** outside a provider rather than returning a silent no-op: every colour token is declared under `[data-theme]`, so a tree with no provider has no colours at all. + +### ThemePreviewSwitcher + +A ready-made icon button that flips between light and dark. It reads `resolved.appearance`, so `system` shows the icon for what is actually on screen. + + + + + +## Per-component radius + +Components take a `radius` prop with the same five values as the theme setting. Two rules distinguish it from a subtree scope: + +1. It affects **only** the component it is set on, never anything inside it. Tree-level changes belong to `ThemePreview`. +2. It does **not compound** with the theme radius — a `large` theme with a `small` component yields small, not large multiplied by small. + + + +`radius` is available on `Button`, `IconButton`, `Badge`, `Callout`, `Chip`, `Input`, `TextArea`, `Image`, `Avatar`, and on the portalled sub-components: `Dialog.Content`, `AlertDialog.Content`, `Drawer.Content`, `Popover.Content`, `Menu.Content`, `ContextMenu.Content`, `Select.Content`, `Combobox.Content`, `Tooltip.Content`, `PreviewCard.Content`, `Command.DialogContent` and `Tour.Content`. + +It lives on the portalled sub-component rather than the root because a portal carries nothing forward: ``, not ``. + +## Portals + +Every portalling component exposes a `container` prop, so portalled content can be placed inside a subtree you control: + +```tsx +… +``` + +You rarely need it for theming — the inherited theme is re-emitted onto the portalled element automatically — but it is the supported way to keep portalled content inside a specific scroll container, dialog, or shadow root. + +## Panel background + +`panelBackground` selects between opaque and translucent overlay surfaces: dialogs, drawers, menus, popovers, selects, comboboxes, tooltips, toasts, preview cards, command palettes and tour cards. The default is `solid`, so translucency is opt-in. + +```tsx + +``` + +## Reduced motion + +`reducedMotion: "system"` is the default and honours `prefers-reduced-motion`, which fifty component stylesheets already respect. A forced `"true"` collapses the duration tokens to a near-zero value, which neutralises transitions and any animation whose duration comes from a token. + +It does not reach animations gated behind `@media (prefers-reduced-motion: no-preference)` blocks. Converting those is tracked separately. + +## Overriding tokens + +Every `--rs-*` declaration in the package is wrapped in `:where()`, so it contributes zero specificity. Every theme element — root, scope or portal re-injection — carries a stable, unhashed `rs-theme` class. A single-class rule of yours therefore beats every built-in token declaration, without `!important` and regardless of stylesheet order: + +```css +.rs-theme { + --rs-color-background-accent-emphasis: #6d28d9; + --rs-radius-3: 10px; +} +``` + +Scope it like any CSS: + +```css +.marketing-page .rs-theme { + --rs-font-title: "Playfair Display", serif; +} +``` + +Inline `style` works too, since tokens now live on a real element: + +```tsx + +``` + +This cuts both ways: an unintended selector can overwrite tokens as easily as an intended one. Given the alternative is overrides that cannot be made to work at all, it is the better failure. + +## Fonts + +Three CSS variables and no prop: + +| Token | Role | +|---|---| +| `--rs-font-body` | Body text | +| `--rs-font-title` | Headings | +| `--rs-font-mono` | Monospace | + +```css +.rs-theme { + --rs-font-body: "Geist", system-ui, sans-serif; + --rs-font-title: "Geist", system-ui, sans-serif; +} +``` + +There is no `fontFamily` prop. A font is a one-time branding choice with no runtime picker, and being free-form it could never be a data attribute like the seven settings. A prop would have to write inline custom properties, which beat every `:where()`-wrapped token rule — making fonts the one part of the token system you could not override from a stylesheet. + +Two stylesheets are published and you import exactly one: + +| Export | Contents | +|---|---| +| `@raystack/apsara/style.css` | Tokens, components, and the font imports | +| `@raystack/apsara/style-no-fonts.css` | Tokens and components, no font imports | + + + Custom fonts carry a caveat, not a guarantee. The typography scale pairs pixel font sizes with pixel line heights, and its letter spacing is tuned for Inter. A font with different metrics leaves line heights uncentred and tracking wrong, and because controls are sized by padding plus line-height, their dimensions shift with it. + + +## Migrating from Theme + +`ThemePreview` is a clean break rather than a superset. Migrate a whole application at once; do not nest the two. + +| Removed | Replacement | +|---|---| +| `theme` | `value.appearance` | +| `defaultTheme` | `defaultValue.appearance` | +| `forcedTheme` | `value.appearance` | +| `accentColor`, `grayColor` as flat props | `defaultValue.accentColor`, `defaultValue.grayColor` | +| `style` | `radius` plus the `--rs-font-*` tokens | +| `onThemeChange` | `onValueChange` | +| `enableSystem` | `appearance: "system"` | +| `enableColorScheme` | Handled by the stylesheet | +| `themes`, `attribute`, `value` as a name-to-attribute map | None. Arbitrary named themes are not supported | +| `ThemeProvider` alias | `ThemePreview` | +| `useTheme().theme` / `.setTheme` / `.resolvedTheme` / `.systemTheme` | `value` / `setValue` / `resolved` / `systemAppearance` | +| `useTheme().themes` / `.forcedTheme` / `.style` / `.scopes` | None | +| `useTheme({ storageKey })` | `useThemePreview().root` | +| `storageKey` | `persistKey`, which now also gates persistence rather than only naming it | +| Persistence at the root by default | `persistKey` is required to persist, at the root as well as in a scope | +| `ThemeSwitcher` | `ThemePreviewSwitcher` | + +### Before and after + +```tsx +// Before + track(resolved)} +> + + + +// After + { + if (changed.appearance) track(value.appearance); + }} +> + + +``` + +```tsx +// Before — force dark for a subtree + + + + +// After + + + +``` + +```tsx +// Before — flip the page theme from inside a scope +const { setTheme } = useTheme({ storageKey: "theme" }); + +// After +const { root } = useThemePreview(); +root.setValue({ appearance: "dark" }); +``` + +### style is retired + +`style="modern" | "traditional"` decomposed exactly into a radius level plus a font pair, both of which are now first-class. Traditional was not a constant multiple of modern — the two scales ran 2/4/6/8/12/16 and 8/16/20/24/32/40 — so it could not survive as a factor without changing its output. It becomes a recipe instead: + +```tsx + +``` + +```css +.rs-theme { + --rs-font-title: "Lora", serif; + --rs-font-body: "Josefin Sans", sans-serif; +} +``` + +### Component radius values + +`Image` and `Avatar` had bespoke radius scales disconnected from the theme; both now use the shared five values. + +- **`Image`** — `none`, `medium` and `full` are unchanged; `small` is now 0.75× the base step rather than a separate token, and `large` is new. +- **`Avatar`** — the default moves from `small` to `medium`, which renders exactly as the old default did. An explicit `radius="small"` is now slightly tighter; `full` is unchanged. + +### Other things to know + +- Tokens are no longer on ``, so consumer CSS and hand-rolled portals living outside the provider stop resolving `--rs-*`. Move them inside, or use a `container` prop. +- `useThemePreview` throws outside a provider instead of returning a no-op. +- The mono font stack now puts JetBrains Mono ahead of Menlo, so the imported face actually renders on macOS. + +## API Reference + +### ThemePreview + + + +### ThemeSettings + + diff --git a/apps/www/src/content/docs/theme/preview/props.ts b/apps/www/src/content/docs/theme/preview/props.ts new file mode 100644 index 000000000..39df2a905 --- /dev/null +++ b/apps/www/src/content/docs/theme/preview/props.ts @@ -0,0 +1,154 @@ +export type Appearance = 'light' | 'dark'; +export type AppearanceSetting = 'light' | 'dark' | 'system'; +export type AccentColor = 'indigo' | 'orange' | 'mint'; +export type GrayColorSetting = 'gray' | 'mauve' | 'slate' | 'sage' | 'auto'; +export type Radius = 'none' | 'small' | 'medium' | 'large' | 'full'; +export type Scaling = '0.9' | '0.95' | '1' | '1.05' | '1.1'; +export type PanelBackground = 'solid' | 'translucent'; +export type ReducedMotion = 'true' | 'false' | 'system'; + +/** One settings object describes the theme. Every key is independent. */ +export type ThemeSettings = { + /** + * Colour scheme. `system` resolves against `prefers-color-scheme`. + * @defaultValue "system" + */ + appearance: AppearanceSetting; + + /** + * Accent ramp. + * @defaultValue "indigo" + */ + accentColor: AccentColor; + + /** + * Gray ramp. `auto` pairs a complementary gray to the accent. + * @defaultValue "auto" + */ + grayColor: GrayColorSetting; + + /** + * Corner radius, applied as a factor over a fixed base scale. + * @defaultValue "medium" + */ + radius: Radius; + + /** + * Zoom. Multiplies spacing, radius, type and line height together. + * @defaultValue "1" + */ + scaling: Scaling; + + /** + * Whether overlay surfaces are opaque or translucent. + * @defaultValue "solid" + */ + panelBackground: PanelBackground; + + /** + * Motion preference. A forced value collapses the duration tokens. + * @defaultValue "system" + */ + reducedMotion: ReducedMotion; +}; + +export type ThemeSettingKey = keyof ThemeSettings; + +export type ThemePreviewProps = { + /** + * Partial settings that seed uncontrolled keys. A stored user choice + * overrides them, so this is a seed rather than a value. + */ + defaultValue?: Partial; + + /** + * Partial settings that are controlled. A controlled key always wins, is + * never persisted, and is never written by the inline script. Control is per + * key: drive `appearance` from a cookie while accent and radius stay + * adjustable. + */ + value?: Partial; + + /** Fires with the full next settings object and the changed subset. */ + onValueChange?: ( + value: ThemeSettings, + changed: Partial + ) => void; + + /** + * Which settings this namespace covers. + * @defaultValue all seven keys + */ + persist?: ThemeSettingKey[]; + + /** + * Storage namespace. Persistence is off unless this is set; a theme without + * one holds its settings in memory and emits no inline script. + */ + persistKey?: string; + + /** + * Whether this theme owns the document's colour scheme. An embedded widget + * or micro-frontend that has no ancestor theme but does not own the page + * must pass `false`. + * @defaultValue true when there is no ancestor theme + */ + isRoot?: boolean; + + /** + * Overrides the painting heuristic: true at the root, true for a nested + * theme that sets an explicit `light` or `dark` appearance, false for one + * that only changes accent, gray, radius or scaling. + */ + hasBackground?: boolean; + + /** + * Suppresses the 0.4s colour transition during an appearance switch. + * @defaultValue false + */ + disableTransitionOnChange?: boolean; + + /** CSP nonce for the inline script. */ + nonce?: string; + + /** `asChild`-style escape hatch: merges the theme onto your own element. */ + render?: React.ReactElement | ((props: object) => React.ReactElement); + + /** Extra classes. `rs-theme` is always present alongside them. */ + className?: string; + + children?: React.ReactNode; +}; + +/** The theme, as read and driven from anywhere inside a provider. */ +export type ThemeHandle = { + /** Settings as set, `system` and `auto` included. */ + value: ThemeSettings; + /** Settings as applied, with `system` and `auto` resolved. */ + resolved: ThemeSettings & { appearance: Appearance }; + /** Takes a partial settings object. Controlled keys are ignored. */ + setValue: (next: Partial) => void; + /** What the OS reports, whatever the current setting is. */ + systemAppearance: Appearance; +}; + +export type UseThemePreviewReturn = ThemeHandle & { + /** + * The same shape bound to the root provider, for flipping the page theme + * from inside a scope. + */ + root: ThemeHandle; +}; + +export type ThemePreviewSwitcherProps = { + /** + * Square size of the button box, in pixels. + * @defaultValue 30 + */ + size?: number; + /** + * Whether to flip the root theme rather than the nearest scope. + * @defaultValue "nearest" + */ + target?: 'nearest' | 'root'; +}; diff --git a/packages/raystack/components/alert-dialog/alert-dialog-content.tsx b/packages/raystack/components/alert-dialog/alert-dialog-content.tsx index 47a17baf1..fef5ac0ce 100644 --- a/packages/raystack/components/alert-dialog/alert-dialog-content.tsx +++ b/packages/raystack/components/alert-dialog/alert-dialog-content.tsx @@ -3,6 +3,12 @@ import { AlertDialog as AlertDialogPrimitive } from '@base-ui/react'; import { cx } from 'class-variance-authority'; import styles from '../dialog/dialog.module.css'; +import { + type PortalContainer, + useThemeInjection +} from '../theme-preview/portal'; +import { radiusClass } from '../theme-preview/radius'; +import type { Radius } from '../theme-preview/settings'; export interface AlertDialogContentProps extends AlertDialogPrimitive.Popup.Props { @@ -12,6 +18,10 @@ export interface AlertDialogContentProps * `@default` true */ showNestedAnimation?: boolean; + /** Portals into this element instead of `document.body`. */ + container?: PortalContainer; + /** Corner radius for this dialog only. Overrides the theme's `radius`. */ + radius?: Radius; } export const AlertDialogContent = ({ @@ -19,10 +29,13 @@ export const AlertDialogContent = ({ children, overlay, showNestedAnimation = true, + container, + radius, ...props }: AlertDialogContentProps) => { + const theme = useThemeInjection(); return ( - + { }); describe('Radius', () => { - const radii = ['small', 'full'] as const; + const radii = ['none', 'small', 'medium', 'large', 'full'] as const; it.each(radii)('renders %s radius', radius => { const { container } = render(); const avatar = container.querySelector('[class*="avatar"]'); - expect(avatar).toHaveClass(styles[`avatar-${radius}`]); + expect(avatar).toHaveClass(radiusClasses[radius]); }); - it('defaults to small radius', () => { + it('defaults to medium radius, which reproduces the old default', () => { const { container } = render(); const avatar = container.querySelector('[class*="avatar"]'); - expect(avatar).toHaveClass(styles['avatar-small']); + expect(avatar).toHaveClass(radiusClasses.medium); + }); + + it('takes its base step from the size class', () => { + const { container } = render(); + const avatar = container.querySelector('[class*="avatar"]'); + expect(avatar).toHaveClass(styles['avatar-size-10']); + expect(avatar).toHaveClass(radiusClasses.medium); }); }); @@ -229,7 +237,7 @@ describe('Avatar', () => { const overflowAvatar = screen .getByText('+1') .closest('[class*="avatar"]'); - expect(overflowAvatar).toHaveClass(styles['avatar-full']); + expect(overflowAvatar).toHaveClass(radiusClasses.full); }); it('matches first avatar variant', () => { diff --git a/packages/raystack/components/avatar/avatar.module.css b/packages/raystack/components/avatar/avatar.module.css index bff9f4f92..af9b36a40 100644 --- a/packages/raystack/components/avatar/avatar.module.css +++ b/packages/raystack/components/avatar/avatar.module.css @@ -16,9 +16,8 @@ --fallback-letter-spacing: 0.03em; } -.avatar.avatar-full { - border-radius: var(--rs-radius-full); -} +/* `radius` comes from the shared override; each `.avatar-size-N` block names + its own `--rs-radius-step`. */ .avatar-disabled { opacity: 0.5; @@ -260,7 +259,8 @@ height: var(--rs-space-5, 16px); --fallback-font-size: calc(var(--rs-space-5, 16px) * 0.4); --fallback-letter-spacing: 0.05em; - border-radius: var(--rs-radius-2); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-2)); + --rs-radius-step: 4px; } .avatar-size-2 { @@ -268,7 +268,8 @@ height: var(--rs-space-6, 20px); --fallback-font-size: calc(var(--rs-space-6, 20px) * 0.4); --fallback-letter-spacing: 0.05em; - border-radius: var(--rs-radius-2); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-2)); + --rs-radius-step: 4px; } .avatar-size-3 { @@ -276,7 +277,8 @@ height: var(--rs-space-7, 24px); --fallback-font-size: calc(var(--rs-space-7, 24px) * 0.4); --fallback-letter-spacing: 0.04em; - border-radius: var(--rs-radius-2); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-2)); + --rs-radius-step: 4px; } .avatar-size-4 { @@ -284,7 +286,8 @@ height: var(--rs-space-8, 28px); --fallback-font-size: calc(var(--rs-space-8, 28px) * 0.35); --fallback-letter-spacing: 0.04em; - border-radius: var(--rs-radius-2); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-2)); + --rs-radius-step: 4px; } .avatar-size-5 { @@ -292,7 +295,8 @@ height: var(--rs-space-9, 32px); --fallback-font-size: calc(var(--rs-space-9, 32px) * 0.4); --fallback-letter-spacing: 0.03em; - border-radius: var(--rs-radius-2); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-2)); + --rs-radius-step: 4px; } .avatar-size-6 { @@ -300,7 +304,8 @@ height: var(--rs-space-10, 40px); --fallback-font-size: calc(var(--rs-space-10, 40px) * 0.35); --fallback-letter-spacing: 0.02em; - border-radius: var(--rs-radius-4); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-4)); + --rs-radius-step: 8px; } .avatar-size-7 { @@ -308,7 +313,8 @@ height: var(--rs-space-11, 48px); --fallback-font-size: calc(var(--rs-space-11, 48px) * 0.35); --fallback-letter-spacing: 0.01em; - border-radius: var(--rs-radius-4); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-4)); + --rs-radius-step: 8px; } .avatar-size-8 { @@ -316,7 +322,8 @@ height: var(--rs-space-12, 56px); --fallback-font-size: calc(var(--rs-space-12, 56px) * 0.3); --fallback-letter-spacing: 0.01em; - border-radius: var(--rs-radius-4); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-4)); + --rs-radius-step: 8px; } .avatar-size-9 { @@ -324,7 +331,8 @@ height: var(--rs-space-13, 64px); --fallback-font-size: calc(var(--rs-space-13, 64px) * 0.3); --fallback-letter-spacing: 0em; - border-radius: var(--rs-radius-4); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-4)); + --rs-radius-step: 8px; } .avatar-size-10 { @@ -332,7 +340,8 @@ height: var(--rs-space-14, 72px); --fallback-font-size: calc(var(--rs-space-14, 72px) * 0.3); --fallback-letter-spacing: 0em; - border-radius: var(--rs-radius-5); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-5)); + --rs-radius-step: 12px; } .avatar-size-11 { @@ -340,7 +349,8 @@ height: var(--rs-space-15, 80px); --fallback-font-size: calc(var(--rs-space-15, 80px) * 0.3); --fallback-letter-spacing: 0em; - border-radius: var(--rs-radius-5); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-5)); + --rs-radius-step: 12px; } .avatar-size-12 { @@ -348,7 +358,8 @@ height: var(--rs-space-16, 96px); --fallback-font-size: calc(var(--rs-space-16, 96px) * 0.3); --fallback-letter-spacing: -0.005em; - border-radius: var(--rs-radius-5); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-5)); + --rs-radius-step: 12px; } .avatar-size-13 { @@ -356,7 +367,8 @@ height: var(--rs-space-17, 120px); --fallback-font-size: calc(var(--rs-space-17, 120px) * 0.3); --fallback-letter-spacing: -0.01em; - border-radius: var(--rs-radius-5); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-5)); + --rs-radius-step: 12px; } /* Avatar Group Styles */ diff --git a/packages/raystack/components/avatar/avatar.tsx b/packages/raystack/components/avatar/avatar.tsx index 11a1d982b..a1b3a4b1c 100644 --- a/packages/raystack/components/avatar/avatar.tsx +++ b/packages/raystack/components/avatar/avatar.tsx @@ -10,6 +10,7 @@ import { useRef, useState } from 'react'; +import { radiusVariants } from '../theme-preview/radius'; import styles from './avatar.module.css'; import { AVATAR_COLORS } from './utils'; @@ -18,10 +19,9 @@ type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error'; const avatar = cva(styles.avatar, { variants: { - radius: { - small: styles['avatar-small'], - full: styles['avatar-full'] - }, + // Each size class names its own base step, so the corner still grows with + // the avatar. `medium` reproduces the previous `small` default exactly. + ...radiusVariants, size: { 1: styles['avatar-size-1'], 2: styles['avatar-size-2'], @@ -125,7 +125,7 @@ const avatar = cva(styles.avatar, { ], defaultVariants: { size: 3, - radius: 'small', + radius: 'medium', variant: 'soft', color: 'indigo' } diff --git a/packages/raystack/components/badge/badge.module.css b/packages/raystack/components/badge/badge.module.css index aff0be440..eae7c6415 100644 --- a/packages/raystack/components/badge/badge.module.css +++ b/packages/raystack/components/badge/badge.module.css @@ -6,7 +6,8 @@ justify-content: center; align-items: center; gap: var(--rs-space-2); - border-radius: var(--rs-radius-1); + --rs-radius-step: 2px; + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-1)); white-space: nowrap; } diff --git a/packages/raystack/components/badge/badge.tsx b/packages/raystack/components/badge/badge.tsx index f0d0ffef7..31377b197 100644 --- a/packages/raystack/components/badge/badge.tsx +++ b/packages/raystack/components/badge/badge.tsx @@ -1,10 +1,12 @@ import { cva, type VariantProps } from 'class-variance-authority'; import { ComponentProps, ReactNode } from 'react'; +import { radiusVariants } from '../theme-preview/radius'; import styles from './badge.module.css'; const badge = cva(styles['badge'], { variants: { + ...radiusVariants, variant: { accent: styles['badge-accent'], warning: styles['badge-warning'], @@ -36,6 +38,7 @@ type BadgeProps = VariantProps & export const Badge = ({ variant = 'accent', size = 'small', + radius, icon, children, className, @@ -44,7 +47,7 @@ export const Badge = ({ }: BadgeProps) => { return ( diff --git a/packages/raystack/components/breadcrumb/breadcrumb.module.css b/packages/raystack/components/breadcrumb/breadcrumb.module.css index 0d9c2742f..e92d25c02 100644 --- a/packages/raystack/components/breadcrumb/breadcrumb.module.css +++ b/packages/raystack/components/breadcrumb/breadcrumb.module.css @@ -51,7 +51,7 @@ .breadcrumb-link:focus-visible { outline: var(--rs-focus-ring); - border-radius: var(--rs-radius-1); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-1)); } .breadcrumb-link-active { @@ -94,7 +94,7 @@ .breadcrumb-dropdown-trigger:focus-visible { outline: var(--rs-focus-ring); - border-radius: var(--rs-radius-1); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-1)); } .breadcrumb-dropdown-icon { @@ -120,5 +120,5 @@ .breadcrumb-dropdown-item:hover { background-color: var(--rs-color-background-base-primary-hover); - border-radius: var(--rs-radius-2); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-2)); } diff --git a/packages/raystack/components/button/button.module.css b/packages/raystack/components/button/button.module.css index ce2993711..e0a8e595e 100644 --- a/packages/raystack/components/button/button.module.css +++ b/packages/raystack/components/button/button.module.css @@ -11,7 +11,8 @@ cursor: pointer; width: fit-content; padding: var(--rs-space-3) var(--rs-space-4); - border-radius: var(--rs-radius-2); + --rs-radius-step: 4px; + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-2)); text-wrap: nowrap; } diff --git a/packages/raystack/components/button/button.tsx b/packages/raystack/components/button/button.tsx index f5ed4afcc..a4c3b1d7d 100644 --- a/packages/raystack/components/button/button.tsx +++ b/packages/raystack/components/button/button.tsx @@ -3,10 +3,12 @@ import { cva, cx, type VariantProps } from 'class-variance-authority'; import { ReactNode } from 'react'; import { Spinner } from '../spinner'; +import { radiusVariants } from '../theme-preview/radius'; import styles from './button.module.css'; const button = cva(styles['button'], { variants: { + ...radiusVariants, variant: { solid: styles['button-solid'], outline: styles['button-outline'], @@ -138,6 +140,7 @@ export const Button = ({ variant = 'solid', color = 'accent', size = 'normal', + radius, disabled, loading, loaderText, @@ -152,7 +155,7 @@ export const Button = ({ return (
& export const Chip = ({ variant, + radius, size, color, trailingIcon, @@ -131,6 +134,7 @@ export const Chip = ({ role={role} className={chip({ variant, + radius, size, color, className: cx(styles['chip-interactive'], className) @@ -149,7 +153,7 @@ export const Chip = ({ data-slot='chip' {...props} {...sharedProps} - className={chip({ variant, size, color, className })} + className={chip({ variant, size, color, radius, className })} role={role ?? 'status'} onClick={disabled ? undefined : onClick} > diff --git a/packages/raystack/components/code-block/code-block.module.css b/packages/raystack/components/code-block/code-block.module.css index 9f2a32aab..fb4d6de78 100644 --- a/packages/raystack/components/code-block/code-block.module.css +++ b/packages/raystack/components/code-block/code-block.module.css @@ -1,6 +1,6 @@ /* Code Block Container */ .container { - border-radius: var(--rs-radius-2); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-2)); overflow: hidden; width: 100%; box-sizing: border-box; @@ -92,7 +92,7 @@ padding: var(--rs-space-2) var(--rs-space-3); width: fit-content; height: auto; - border-radius: var(--rs-radius-2); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-2)); border: 0.5px solid var(--rs-color-border-base-tertiary); background: var(--rs-color-background-base-primary); box-shadow: var(--rs-shadow-feather); diff --git a/packages/raystack/components/color-picker/color-picker.module.css b/packages/raystack/components/color-picker/color-picker.module.css index 8d94d8ad0..08573412c 100644 --- a/packages/raystack/components/color-picker/color-picker.module.css +++ b/packages/raystack/components/color-picker/color-picker.module.css @@ -25,7 +25,7 @@ height: var(--rs-space-4); width: 100%; flex-grow: 1; - border-radius: var(--rs-radius-3); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-3)); } .hueTrack { @@ -115,7 +115,7 @@ branch has no such element. aspect-ratio gives both branches a square footprint derived from width. */ aspect-ratio: 1 / 1; - border-radius: var(--rs-radius-1); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-1)); overflow: hidden; /* thumb transform below resolves 100cqw/100cqh against this box */ container-type: size; diff --git a/packages/raystack/components/combobox/combobox-content.tsx b/packages/raystack/components/combobox/combobox-content.tsx index f6cfeb98e..6b1b06099 100644 --- a/packages/raystack/components/combobox/combobox-content.tsx +++ b/packages/raystack/components/combobox/combobox-content.tsx @@ -2,6 +2,12 @@ import { Combobox as ComboboxPrimitive } from '@base-ui/react'; import { cx } from 'class-variance-authority'; +import { + type PortalContainer, + useThemeInjection +} from '../theme-preview/portal'; +import { radiusClass } from '../theme-preview/radius'; +import type { Radius } from '../theme-preview/settings'; import styles from './combobox.module.css'; import { useComboboxContext } from './combobox-root'; @@ -10,7 +16,12 @@ export interface ComboboxContentProps ComboboxPrimitive.Positioner.Props, 'render' | 'className' | 'style' >, - ComboboxPrimitive.Popup.Props {} + ComboboxPrimitive.Popup.Props { + /** Portals into this element instead of `document.body`. */ + container?: PortalContainer; + /** Corner radius for this popup only. Overrides the theme's `radius`. */ + radius?: Radius; +} export const ComboboxContent = ({ ref, @@ -21,11 +32,14 @@ export const ComboboxContent = ({ initialFocus, finalFocus, sideOffset = 4, + container, + radius, ...positionerProps }: ComboboxContentProps) => { const { inputContainerRef } = useComboboxContext(); + const theme = useThemeInjection(); return ( - + ( @@ -24,6 +30,10 @@ CommandDialogTrigger.displayName = 'Command.DialogTrigger'; export interface CommandDialogContentProps extends DialogPrimitive.Popup.Props { width?: string | number; + /** Portals into this element instead of `document.body`. */ + container?: PortalContainer; + /** Corner radius for this palette only. Overrides the theme's `radius`. */ + radius?: Radius; } export function CommandDialogContent({ @@ -31,6 +41,8 @@ export function CommandDialogContent({ children, width, style, + container, + radius, ...props }: CommandDialogContentProps) { const popupRef = useRef(null); @@ -41,17 +53,24 @@ export function CommandDialogContent({ usually behave: focus goes back to the trigger only when you opened it by clicking the trigger. */ const originRef = useRef(null); + const theme = useThemeInjection(); return ( - + { /* Runs before focus moves into the popup, so activeElement is diff --git a/packages/raystack/components/command/command.module.css b/packages/raystack/components/command/command.module.css index 4cc5f9ca5..2b26cf28e 100644 --- a/packages/raystack/components/command/command.module.css +++ b/packages/raystack/components/command/command.module.css @@ -4,7 +4,8 @@ overflow: hidden; isolation: isolate; width: 100%; - background-color: var(--rs-color-background-base-primary); + background-color: var(--rs-color-panel); + backdrop-filter: var(--rs-panel-backdrop-filter); } .inputWrapper { @@ -51,7 +52,7 @@ letter-spacing: var(--rs-letter-spacing-small); color: var(--rs-color-foreground-base-primary); background-color: var(--rs-color-background-base-primary); - border-radius: var(--rs-radius-2); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-2)); cursor: pointer; outline: none; user-select: none; @@ -165,8 +166,10 @@ centered on short ones so the popup stays inside the viewport. The input stays put while the list below changes height. */ transform: translate(-50%, min(160px, calc(50vh - 50%))); - background-color: var(--rs-color-background-base-primary); - border-radius: var(--rs-radius-2); + background-color: var(--rs-color-panel); + backdrop-filter: var(--rs-panel-backdrop-filter); + --rs-radius-step: 4px; + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-2)); box-shadow: var(--rs-shadow-floating); overflow: hidden; outline: none; diff --git a/packages/raystack/components/context-menu/context-menu-content.tsx b/packages/raystack/components/context-menu/context-menu-content.tsx index fe497ab05..8750fc4f1 100644 --- a/packages/raystack/components/context-menu/context-menu-content.tsx +++ b/packages/raystack/components/context-menu/context-menu-content.tsx @@ -14,6 +14,12 @@ import { isElementSubMenuTrigger, KEYCODES } from '../menu/utils'; +import { + type PortalContainer, + useThemeInjection +} from '../theme-preview/portal'; +import { radiusClass } from '../theme-preview/radius'; +import type { Radius } from '../theme-preview/settings'; export interface ContextMenuContentProps extends Omit< @@ -22,6 +28,10 @@ export interface ContextMenuContentProps >, ContextMenuPrimitive.Popup.Props { searchPlaceholder?: string; + /** Portals into this element instead of `document.body`. */ + container?: PortalContainer; + /** Corner radius for this menu only. Overrides the theme's `radius`. */ + radius?: Radius; } export const ContextMenuContent = ({ @@ -35,6 +45,8 @@ export const ContextMenuContent = ({ sideOffset = 4, align = 'start', onFocus, + container, + radius, ...positionerProps }: ContextMenuContentProps) => { const { @@ -97,8 +109,10 @@ export const ContextMenuContent = ({ item.dispatchEvent(new PointerEvent('pointerout', { bubbles: true })); }, []); + const theme = useThemeInjection(); + return ( - + + + { }); describe('Radius Variants', () => { - const radiuses = ['none', 'small', 'medium', 'full'] as const; + const radiuses = ['none', 'small', 'medium', 'large', 'full'] as const; it.each(radiuses)('renders %s radius correctly', radius => { render(Test); const img = screen.getByRole('img'); - expect(img).toHaveClass(styles[`image-radius-${radius}`]); + expect(img).toHaveClass(radiusClasses[radius]); }); it('defaults to none radius', () => { render(Test); const img = screen.getByRole('img'); - expect(img).toHaveClass(styles['image-radius-none']); + expect(img).toHaveClass(radiusClasses.none); }); }); diff --git a/packages/raystack/components/image/image.module.css b/packages/raystack/components/image/image.module.css index cb9774b5c..2c58fd079 100644 --- a/packages/raystack/components/image/image.module.css +++ b/packages/raystack/components/image/image.module.css @@ -1,4 +1,7 @@ .image { + /* Base step for the shared `radius` override; `medium` reproduces the + previous `--rs-radius-3`. */ + --rs-radius-step: 6px; display: block; max-width: 100%; height: auto; @@ -20,22 +23,6 @@ object-fit: fill; } -.image-radius-none { - border-radius: 0; -} - -.image-radius-small { - border-radius: var(--rs-radius-2); -} - -.image-radius-medium { - border-radius: var(--rs-radius-3); -} - -.image-radius-full { - border-radius: var(--rs-radius-full); -} - /* Load fade: hidden only after JS confirms an in-flight load, so SSR/no-JS images are never invisible. Cached images skip this entirely. */ .image-loading { diff --git a/packages/raystack/components/image/image.tsx b/packages/raystack/components/image/image.tsx index deb7ffb00..9a5f02f87 100644 --- a/packages/raystack/components/image/image.tsx +++ b/packages/raystack/components/image/image.tsx @@ -4,6 +4,7 @@ import { cva, cx, type VariantProps } from 'class-variance-authority'; import { ComponentProps, SyntheticEvent, useRef, useState } from 'react'; import { useIsomorphicLayoutEffect } from '~/hooks'; +import { radiusVariants } from '../theme-preview/radius'; import styles from './image.module.css'; const image = cva(styles.image, { @@ -13,12 +14,8 @@ const image = cva(styles.image, { cover: styles['image-cover'], fill: styles['image-fill'] }, - radius: { - none: styles['image-radius-none'], - small: styles['image-radius-small'], - medium: styles['image-radius-medium'], - full: styles['image-radius-full'] - } + // The base step lives in `image.module.css` as `--rs-radius-step`. + ...radiusVariants }, defaultVariants: { fit: 'cover', diff --git a/packages/raystack/components/input/input.module.css b/packages/raystack/components/input/input.module.css index f20e89dcd..964079028 100644 --- a/packages/raystack/components/input/input.module.css +++ b/packages/raystack/components/input/input.module.css @@ -5,7 +5,8 @@ align-items: center; width: 100%; position: relative; - border-radius: var(--rs-radius-2); + --rs-radius-step: 4px; + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-2)); border: 0.5px solid var(--rs-color-border-base-tertiary); background: var(--rs-color-background-base-primary); transition: var(--rs-transition-interactive); @@ -168,7 +169,7 @@ .chip-overflow { color: var(--rs-color-foreground-base-primary); font-size: var(--rs-font-size-small); - border-radius: var(--rs-radius-2); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-2)); } .prefix, @@ -184,7 +185,7 @@ padding: 0 var(--rs-space-2); pointer-events: none; background: var(--rs-color-background-neutral-secondary); - border-radius: var(--rs-radius-2); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-2)); white-space: nowrap; height: auto; } diff --git a/packages/raystack/components/input/input.tsx b/packages/raystack/components/input/input.tsx index 506d2d658..d9d6ead93 100644 --- a/packages/raystack/components/input/input.tsx +++ b/packages/raystack/components/input/input.tsx @@ -3,10 +3,12 @@ import { cva, cx, type VariantProps } from 'class-variance-authority'; import { ReactNode, RefObject } from 'react'; import { Chip } from '../chip'; import { useFieldContext } from '../field'; +import { radiusVariants } from '../theme-preview/radius'; import styles from './input.module.css'; const inputWrapper = cva(styles['input-wrapper'], { variants: { + ...radiusVariants, size: { small: styles['size-small'], large: styles['size-large'] @@ -49,6 +51,7 @@ export function Input({ chips, maxChipsVisible = 2, size, + radius, variant = 'default', containerRef, classNames, @@ -61,7 +64,7 @@ export function Input({ return (
, MenuPrimitive.Popup.Props { searchPlaceholder?: string; + /** Portals into this element instead of `document.body`. */ + container?: PortalContainer; + /** Corner radius for this menu only. Overrides the theme's `radius`. */ + radius?: Radius; } export function MenuContent({ @@ -35,6 +45,8 @@ export function MenuContent({ sideOffset = 4, align = 'start', onFocus, + container, + radius, ...positionerProps }: MenuContentProps) { const { @@ -97,8 +109,10 @@ export function MenuContent({ item.dispatchEvent(new PointerEvent('pointerout', { bubbles: true })); }, []); + const theme = useThemeInjection(); + return ( - + , - PopoverPrimitive.Popup.Props {} + PopoverPrimitive.Popup.Props { + /** Portals into this element instead of `document.body`. */ + container?: PortalContainer; + /** Corner radius for this popup only. Overrides the theme's `radius`. */ + radius?: Radius; +} function PopoverContent({ ref, @@ -19,10 +30,13 @@ function PopoverContent({ style, render, children, + container, + radius, ...positionerProps }: PopoverContentProps) { + const theme = useThemeInjection(); return ( - + + , SelectPrimitive.Popup.Props { searchPlaceholder?: string; + /** + * Portals into this element instead of `document.body`. Only the + * autocomplete variant portals; the plain variant keeps its items in the + * DOM so the trigger can display the selected value. + */ + container?: PortalContainer; + /** Corner radius for this popup only. Overrides the theme's `radius`. */ + radius?: Radius; } export function SelectContent({ @@ -24,13 +38,16 @@ export function SelectContent({ sideOffset = 4, side = 'bottom', align = 'start', + container, + radius, ...props }: SelectContentProps) { const { autocomplete, multiple } = useSelectContext(); + const theme = useThemeInjection(); if (autocomplete) { return ( - + { + installLocalStorage(); + installMatchMedia(false); + clearThemeStorageCache(); +}); + +describe('ThemePreview data-slot contract', () => { + it('exposes the theme element slot', () => { + const { container } = render( + +
child
+
+ ); + expectSlots(container, ['theme-preview']); + }); + + it('exposes the script slot only for a persisted namespace', () => { + const { container: plain } = render(child); + expect(getSlot(plain, 'theme-preview-script')).toBeNull(); + + const { container: persisted } = render( + child + ); + expect(getSlot(persisted, 'theme-preview-script')?.tagName).toBe('SCRIPT'); + }); + + it('exposes the slot on a nested scope too', () => { + const { container } = render( + + +
nested
+
+
+ ); + expect( + container.querySelectorAll('[data-slot="theme-preview"]') + ).toHaveLength(2); + }); + + it('exposes the switcher slot', () => { + const { container } = render( + + + + ); + expectSlots(container, ['theme-preview-switcher']); + }); +}); diff --git a/packages/raystack/components/theme-preview/__tests__/mocks.ts b/packages/raystack/components/theme-preview/__tests__/mocks.ts new file mode 100644 index 000000000..e6edf9df5 --- /dev/null +++ b/packages/raystack/components/theme-preview/__tests__/mocks.ts @@ -0,0 +1,87 @@ +import { act } from '@testing-library/react'; +import { vi } from 'vitest'; + +/** + * A real in-memory `localStorage`. The theme round-trips JSON through it, so a + * mock that only records calls cannot exercise the merge or the cache. + */ +export function installLocalStorage(): Map { + const entries = new Map(); + const storage: Storage = { + getItem: key => (entries.has(key) ? (entries.get(key) as string) : null), + setItem: (key, value) => { + entries.set(key, String(value)); + }, + removeItem: key => { + entries.delete(key); + }, + clear: () => entries.clear(), + key: index => Array.from(entries.keys())[index] ?? null, + get length() { + return entries.size; + } + }; + Object.defineProperty(window, 'localStorage', { + configurable: true, + writable: true, + value: storage + }); + return entries; +} + +type MediaListener = (event: MediaQueryListEvent) => void; + +export interface MediaController { + /** Flips what the OS reports and notifies every listener. */ + setPrefersDark: (next: boolean) => void; + matchMedia: ReturnType; +} + +/** jsdom ships no `matchMedia`; the theme needs one that can change. */ +export function installMatchMedia(initialDark = false): MediaController { + let prefersDark = initialDark; + const listeners = new Set(); + + const matchMedia = vi.fn((query: string) => ({ + get matches() { + return query.includes('dark') ? prefersDark : false; + }, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: (_type: string, listener: MediaListener) => { + listeners.add(listener); + }, + removeEventListener: (_type: string, listener: MediaListener) => { + listeners.delete(listener); + }, + dispatchEvent: vi.fn() + })); + + Object.defineProperty(window, 'matchMedia', { + configurable: true, + writable: true, + value: matchMedia + }); + + return { + matchMedia, + setPrefersDark: next => { + prefersDark = next; + act(() => { + for (const listener of listeners) { + listener({ matches: next } as MediaQueryListEvent); + } + }); + } + }; +} + +/** Serialises a settings object the way the theme stores it. */ +export function storedEntry( + settings: Record, + version = 1 +): string { + return JSON.stringify({ v: version, settings }); +} diff --git a/packages/raystack/components/theme-preview/__tests__/script.test.ts b/packages/raystack/components/theme-preview/__tests__/script.test.ts new file mode 100644 index 000000000..d08e9dfa2 --- /dev/null +++ b/packages/raystack/components/theme-preview/__tests__/script.test.ts @@ -0,0 +1,185 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { createThemeScript } from '../script'; +import { installLocalStorage, installMatchMedia, storedEntry } from './mocks'; + +let entries: Map; + +beforeEach(() => { + entries = installLocalStorage(); + installMatchMedia(false); + document.body.innerHTML = ''; +}); + +/** Runs a generated script the way the browser would: as its own child. */ +function run(source: string, parent: HTMLElement): void { + const script = document.createElement('script'); + parent.appendChild(script); + Object.defineProperty(document, 'currentScript', { + configurable: true, + get: () => script + }); + try { + new Function(source)(); + } finally { + Object.defineProperty(document, 'currentScript', { + configurable: true, + get: () => null + }); + } +} + +function themeElement(attributes: Record = {}): HTMLDivElement { + const element = document.createElement('div'); + element.className = 'rs-theme'; + element.setAttribute('data-theme', 'light'); + element.setAttribute('data-accent-color', 'indigo'); + element.setAttribute('data-gray-color', 'slate'); + element.setAttribute('data-rs-theme-id', 'rs-theme-abc'); + for (const [name, value] of Object.entries(attributes)) { + element.setAttribute(name, value); + } + document.body.appendChild(element); + return element; +} + +describe('createThemeScript', () => { + it('emits nothing when there are no uncontrolled persistable keys', () => { + expect( + createThemeScript({ persistKey: 'app', keys: [], elementId: 'x' }) + ).toBeNull(); + }); + + it('escapes characters that could close the script tag', () => { + const source = createThemeScript({ + persistKey: '', + keys: ['appearance'], + elementId: 'x' + }); + expect(source).not.toContain(''); + expect(source).toContain('\\u003c'); + }); +}); + +describe('the generated script', () => { + it('patches its own parent from the stored value', () => { + entries.set('app', storedEntry({ appearance: 'dark' })); + const element = themeElement(); + const source = createThemeScript({ + persistKey: 'app', + keys: ['appearance'], + elementId: 'rs-theme-abc' + }) as string; + + run(source, element); + + expect(element.getAttribute('data-theme')).toBe('dark'); + }); + + it('resolves a stored `system` appearance against the OS', () => { + installMatchMedia(true); + entries.set('app', storedEntry({ appearance: 'system' })); + const element = themeElement(); + const source = createThemeScript({ + persistKey: 'app', + keys: ['appearance'], + elementId: 'rs-theme-abc' + }) as string; + + run(source, element); + + expect(element.getAttribute('data-theme')).toBe('dark'); + }); + + it('resolves a stored `auto` gray against the accent it just wrote', () => { + entries.set( + 'app', + storedEntry({ accentColor: 'orange', grayColor: 'auto' }) + ); + const element = themeElement(); + const source = createThemeScript({ + persistKey: 'app', + keys: ['accentColor', 'grayColor'], + elementId: 'rs-theme-abc' + }) as string; + + run(source, element); + + expect(element.getAttribute('data-accent-color')).toBe('orange'); + expect(element.getAttribute('data-gray-color')).toBe('mauve'); + }); + + it('leaves the server-rendered attribute when the entry is absent', () => { + const element = themeElement(); + const source = createThemeScript({ + persistKey: 'app', + keys: ['appearance'], + elementId: 'rs-theme-abc' + }) as string; + + run(source, element); + + expect(element.getAttribute('data-theme')).toBe('light'); + }); + + it('leaves the server-rendered attribute when the entry is malformed', () => { + entries.set('app', '{ broken'); + const element = themeElement(); + const source = createThemeScript({ + persistKey: 'app', + keys: ['appearance'], + elementId: 'rs-theme-abc' + }) as string; + + run(source, element); + + expect(element.getAttribute('data-theme')).toBe('light'); + }); + + it('leaves the server-rendered attribute for an out-of-union value', () => { + entries.set('app', storedEntry({ appearance: 'ultraviolet' })); + const element = themeElement(); + const source = createThemeScript({ + persistKey: 'app', + keys: ['appearance'], + elementId: 'rs-theme-abc' + }) as string; + + run(source, element); + + expect(element.getAttribute('data-theme')).toBe('light'); + }); + + it('falls back to a selector when currentScript is unavailable', () => { + entries.set('app', storedEntry({ appearance: 'dark' })); + const element = themeElement(); + const source = createThemeScript({ + persistKey: 'app', + keys: ['appearance'], + elementId: 'rs-theme-abc' + }) as string; + + Object.defineProperty(document, 'currentScript', { + configurable: true, + get: () => null + }); + new Function(source)(); + + expect(element.getAttribute('data-theme')).toBe('dark'); + }); + + it('never writes a key it was not given, even when one is stored', () => { + entries.set('app', storedEntry({ appearance: 'dark', radius: 'full' })); + const element = themeElement({ 'data-radius': 'medium' }); + const source = createThemeScript({ + persistKey: 'app', + keys: ['appearance'], + elementId: 'rs-theme-abc' + }) as string; + + run(source, element); + + expect(element.getAttribute('data-theme')).toBe('dark'); + expect(element.getAttribute('data-radius')).toBe('medium'); + }); +}); diff --git a/packages/raystack/components/theme-preview/__tests__/ssr.test.tsx b/packages/raystack/components/theme-preview/__tests__/ssr.test.tsx new file mode 100644 index 000000000..11bb61069 --- /dev/null +++ b/packages/raystack/components/theme-preview/__tests__/ssr.test.tsx @@ -0,0 +1,171 @@ +import { act } from '@testing-library/react'; +import { hydrateRoot } from 'react-dom/client'; +import { renderToString } from 'react-dom/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useThemePreview } from '../context'; +import { clearThemeStorageCache } from '../store'; +import { ThemePreview } from '../theme-preview'; +import { installLocalStorage, installMatchMedia, storedEntry } from './mocks'; + +let entries: Map; + +beforeEach(() => { + entries = installLocalStorage(); + installMatchMedia(false); + clearThemeStorageCache(); + document.body.innerHTML = ''; +}); + +/** Runs the inline script the way the browser would, before hydration. */ +function runInlineScript(container: HTMLElement): void { + const script = container.querySelector('script'); + if (!script) return; + Object.defineProperty(document, 'currentScript', { + configurable: true, + get: () => script + }); + try { + new Function(script.textContent as string)(); + } finally { + Object.defineProperty(document, 'currentScript', { + configurable: true, + get: () => null + }); + } +} + +describe('server rendering', () => { + it('renders every setting as an attribute on the first byte', () => { + entries.set('app', storedEntry({ appearance: 'dark' })); + const html = renderToString( + + content + + ); + + // The server snapshot returns the seed, so the hydration render matches. + expect(html).toContain('data-theme="light"'); + expect(html).toContain('data-accent-color="mint"'); + expect(html).toContain('data-radius="medium"'); + expect(html).toContain('data-scaling="1"'); + }); + + it('renders the script inside the theme element, as its first child', () => { + const html = renderToString( + content + ); + const container = document.createElement('div'); + container.innerHTML = html; + const theme = container.querySelector('.rs-theme') as HTMLElement; + + expect(theme.firstElementChild?.tagName).toBe('SCRIPT'); + }); + + it('emits no script and reads no storage when persistence is off', () => { + const getItem = vi.spyOn(window.localStorage, 'getItem'); + const html = renderToString(content); + expect(html).not.toContain(' { + const html = renderToString( + + content + + ); + expect(html).toContain('nonce="abc123"'); + }); +}); + +describe('hydration', () => { + it('keeps the value the script patched in, with no mismatch', async () => { + entries.set('app', storedEntry({ appearance: 'dark' })); + + const tree = ( + + content + + ); + + const container = document.createElement('div'); + container.innerHTML = renderToString(tree); + document.body.appendChild(container); + + // The server wrote `light`; the script corrects the DOM before paint. + const theme = container.querySelector('.rs-theme') as HTMLElement; + expect(theme.getAttribute('data-theme')).toBe('light'); + runInlineScript(theme); + expect(theme.getAttribute('data-theme')).toBe('dark'); + + const error = vi.spyOn(console, 'error').mockImplementation(() => { + /* swallow React's expected error logging */ + }); + await act(async () => { + hydrateRoot(container, tree); + }); + + // The post-hydration snapshot returns the same value, so nothing moves. + expect(theme.getAttribute('data-theme')).toBe('dark'); + const hydrationWarnings = error.mock.calls.filter(call => + String(call[0]).includes('did not match') + ); + expect(hydrationWarnings).toHaveLength(0); + error.mockRestore(); + }); + + it('reconciles the element when no script ran to correct it', async () => { + // `system` with no persistence: the server guesses light, nothing patches + // the DOM, and React does not fix attribute mismatches during hydration. + installMatchMedia(true); + + const tree = ( + + content + + ); + + const container = document.createElement('div'); + container.innerHTML = renderToString(tree); + document.body.appendChild(container); + const theme = container.querySelector('.rs-theme') as HTMLElement; + expect(theme.getAttribute('data-theme')).toBe('light'); + + const error = vi.spyOn(console, 'error').mockImplementation(() => { + /* swallow React's expected error logging */ + }); + await act(async () => { + hydrateRoot(container, tree); + }); + error.mockRestore(); + + expect(theme.getAttribute('data-theme')).toBe('dark'); + }); + + it('gives the hook the stored value after hydration', async () => { + entries.set('app', storedEntry({ radius: 'full' })); + let seen: string | undefined; + function Probe() { + seen = useThemePreview().resolved.radius; + return null; + } + + const tree = ( + + + + ); + + const container = document.createElement('div'); + container.innerHTML = renderToString(tree); + document.body.appendChild(container); + expect(seen).toBe('medium'); + + await act(async () => { + hydrateRoot(container, tree); + }); + + expect(seen).toBe('full'); + }); +}); diff --git a/packages/raystack/components/theme-preview/__tests__/store.test.ts b/packages/raystack/components/theme-preview/__tests__/store.test.ts new file mode 100644 index 000000000..dc01a4103 --- /dev/null +++ b/packages/raystack/components/theme-preview/__tests__/store.test.ts @@ -0,0 +1,160 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + clearThemeStorageCache, + readServerSettings, + readStoredSettings, + subscribeToThemeStorage, + THEME_STORAGE_EVENT, + writeStoredSettings +} from '../store'; +import { installLocalStorage, storedEntry } from './mocks'; + +let entries: Map; + +beforeEach(() => { + entries = installLocalStorage(); + clearThemeStorageCache(); +}); + +describe('readStoredSettings', () => { + it('reads the settings a namespace holds', () => { + entries.set('app', storedEntry({ appearance: 'dark', radius: 'large' })); + expect(readStoredSettings('app')).toEqual({ + appearance: 'dark', + radius: 'large' + }); + }); + + it('returns nothing without a persistKey, and never touches storage', () => { + entries.set('app', storedEntry({ appearance: 'dark' })); + expect(readStoredSettings(undefined)).toEqual({}); + }); + + it('falls back to the seed when the entry is missing', () => { + expect(readStoredSettings('app')).toEqual({}); + }); + + it('falls back to the seed when the entry is unparseable', () => { + entries.set('app', '{not json'); + expect(readStoredSettings('app')).toEqual({}); + }); + + it('falls back to the seed for a bare legacy theme name', () => { + // The previous provider stored `"dark"`, which is valid JSON but not an + // object, and is therefore detectable rather than silently accepted. + entries.set('app', JSON.stringify('dark')); + expect(readStoredSettings('app')).toEqual({}); + }); + + it('ignores an entry written by a newer schema version', () => { + entries.set('app', storedEntry({ appearance: 'dark' }, 99)); + expect(readStoredSettings('app')).toEqual({}); + }); + + it('discards an out-of-union field individually', () => { + entries.set( + 'app', + storedEntry({ appearance: 'ultraviolet', radius: 'large' }) + ); + expect(readStoredSettings('app')).toEqual({ radius: 'large' }); + }); + + it('holds snapshot identity while the stored string is unchanged', () => { + entries.set('app', storedEntry({ appearance: 'dark' })); + const first = readStoredSettings('app'); + const second = readStoredSettings('app'); + // `useSyncExternalStore` compares with `Object.is` and accepts no equality + // function, so a freshly parsed object each call would re-render forever. + expect(second).toBe(first); + }); + + it('returns a new snapshot once the stored string changes', () => { + entries.set('app', storedEntry({ appearance: 'dark' })); + const first = readStoredSettings('app'); + entries.set('app', storedEntry({ appearance: 'light' })); + const second = readStoredSettings('app'); + expect(second).not.toBe(first); + expect(second).toEqual({ appearance: 'light' }); + }); + + it('holds identity across empty results too', () => { + expect(readStoredSettings('app')).toBe(readStoredSettings('other')); + }); + + it('returns the seed as the server snapshot', () => { + expect(readServerSettings()).toEqual({}); + }); +}); + +describe('writeStoredSettings', () => { + it('writes a versioned object', () => { + writeStoredSettings('app', ['appearance'], { appearance: 'dark' }); + expect(JSON.parse(entries.get('app') as string)).toEqual({ + v: 1, + settings: { appearance: 'dark' } + }); + }); + + it('merges rather than replaces', () => { + entries.set('app', storedEntry({ radius: 'large', accentColor: 'mint' })); + writeStoredSettings('app', ['appearance'], { appearance: 'dark' }); + expect(readStoredSettings('app')).toEqual({ + radius: 'large', + accentColor: 'mint', + appearance: 'dark' + }); + }); + + it('applies only the settings its persist list covers', () => { + writeStoredSettings('app', ['appearance'], { + appearance: 'dark', + radius: 'full' + }); + expect(readStoredSettings('app')).toEqual({ appearance: 'dark' }); + }); + + it('leaves fields owned by a theme with a different persist intact', () => { + writeStoredSettings('app', ['radius'], { radius: 'full' }); + writeStoredSettings('app', ['appearance'], { appearance: 'dark' }); + expect(readStoredSettings('app')).toEqual({ + radius: 'full', + appearance: 'dark' + }); + }); + + it('notifies in-document readers, which the storage event does not', () => { + let notified = 0; + const unsubscribe = subscribeToThemeStorage(() => { + notified += 1; + }); + writeStoredSettings('app', ['appearance'], { appearance: 'dark' }); + unsubscribe(); + expect(notified).toBe(1); + }); + + it('does not notify when nothing actually changed', () => { + writeStoredSettings('app', ['appearance'], { appearance: 'dark' }); + let notified = 0; + const unsubscribe = subscribeToThemeStorage(() => { + notified += 1; + }); + writeStoredSettings('app', ['appearance'], { appearance: 'dark' }); + unsubscribe(); + expect(notified).toBe(0); + }); +}); + +describe('subscribeToThemeStorage', () => { + it('listens to the storage event for other tabs', () => { + let notified = 0; + const unsubscribe = subscribeToThemeStorage(() => { + notified += 1; + }); + window.dispatchEvent(new Event('storage')); + window.dispatchEvent(new Event(THEME_STORAGE_EVENT)); + unsubscribe(); + window.dispatchEvent(new Event('storage')); + expect(notified).toBe(2); + }); +}); diff --git a/packages/raystack/components/theme-preview/__tests__/theme-preview.test.tsx b/packages/raystack/components/theme-preview/__tests__/theme-preview.test.tsx new file mode 100644 index 000000000..df6d967e8 --- /dev/null +++ b/packages/raystack/components/theme-preview/__tests__/theme-preview.test.tsx @@ -0,0 +1,721 @@ +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { type ReactNode, useEffect } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useThemePreview } from '../context'; +import { useThemeInjection } from '../portal'; +import { radiusClass } from '../radius'; +import type { ThemeSettings } from '../settings'; +import { clearThemeStorageCache } from '../store'; +import { ThemePreview } from '../theme-preview'; +import { + installLocalStorage, + installMatchMedia, + type MediaController, + storedEntry +} from './mocks'; + +let entries: Map; +let media: MediaController; + +beforeEach(() => { + entries = installLocalStorage(); + media = installMatchMedia(false); + clearThemeStorageCache(); +}); + +/** The element every theme carries the documented override class on. */ +function themeElement(container: HTMLElement, index = 0): HTMLElement { + const elements = container.querySelectorAll('.rs-theme'); + const element = elements[index]; + if (!element) throw new Error(`No theme element at index ${index}`); + return element; +} + +function Probe({ label = 'probe' }: { label?: string }) { + const theme = useThemePreview(); + return ( + + {JSON.stringify({ value: theme.value, resolved: theme.resolved })} + + ); +} + +function readProbe(label = 'probe'): { + value: ThemeSettings; + resolved: ThemeSettings; +} { + return JSON.parse(screen.getByTestId(label).textContent as string); +} + +// ─── Attributes ───────────────────────────────────────────────────────────── + +describe('ThemePreview attributes', () => { + it('writes every setting as a data attribute on its own element', () => { + const { container } = render(content); + const element = themeElement(container); + + expect(element).toHaveAttribute('data-theme', 'light'); + expect(element).toHaveAttribute('data-accent-color', 'indigo'); + expect(element).toHaveAttribute('data-gray-color', 'slate'); + expect(element).toHaveAttribute('data-radius', 'medium'); + expect(element).toHaveAttribute('data-scaling', '1'); + expect(element).toHaveAttribute('data-panel-background', 'solid'); + expect(element).toHaveAttribute('data-reduced-motion', 'system'); + }); + + it('writes nothing to the document element', () => { + render(content); + expect(document.documentElement.hasAttribute('data-theme')).toBe(false); + expect(document.documentElement.hasAttribute('data-accent-color')).toBe( + false + ); + }); + + it('carries the stable rs-theme override class', () => { + const { container } = render( + content + ); + const element = themeElement(container); + expect(element).toHaveClass('rs-theme'); + expect(element).toHaveClass('mine'); + }); + + it('lets a nested scope layer settings over its parent', () => { + const { container } = render( + + + scoped + + + ); + + const scope = themeElement(container, 1); + expect(scope).toHaveAttribute('data-accent-color', 'mint'); + // Inherited by omission: a nested theme keeps every key it does not set. + expect(scope).toHaveAttribute('data-radius', 'large'); + }); +}); + +// ─── Root marker and background ───────────────────────────────────────────── + +describe('the root marker', () => { + it('marks a theme with no ancestor', () => { + const { container } = render(content); + expect(themeElement(container)).toHaveAttribute('data-rs-root'); + }); + + it('does not mark a nested theme', () => { + const { container } = render( + + scoped + + ); + expect(themeElement(container, 1)).not.toHaveAttribute('data-rs-root'); + }); + + it('isRoot={false} suppresses the marker but leaves the theme intact', () => { + const { container } = render( + + widget + + ); + const element = themeElement(container); + expect(element).not.toHaveAttribute('data-rs-root'); + expect(element).toHaveAttribute('data-theme', 'dark'); + expect(element).toHaveAttribute('data-accent-color', 'indigo'); + }); +}); + +describe('hasBackground', () => { + it('paints at the root by default', () => { + const { container } = render(content); + expect(themeElement(container)).toHaveAttribute('data-rs-background'); + }); + + it('paints a nested theme that sets an explicit appearance', () => { + const { container } = render( + + panel + + ); + expect(themeElement(container, 1)).toHaveAttribute('data-rs-background'); + }); + + it('does not paint a nested theme that only re-tints', () => { + const { container } = render( + + tint + + ); + expect(themeElement(container, 1)).not.toHaveAttribute( + 'data-rs-background' + ); + }); + + it('honours an explicit override', () => { + const { container } = render( + content + ); + expect(themeElement(container)).not.toHaveAttribute('data-rs-background'); + }); +}); + +// ─── Controlled and uncontrolled ──────────────────────────────────────────── + +describe('controlled versus uncontrolled precedence', () => { + it('a controlled key ignores a stored value', () => { + entries.set('app', storedEntry({ appearance: 'dark' })); + const { container } = render( + + content + + ); + expect(themeElement(container)).toHaveAttribute('data-theme', 'light'); + }); + + it('a stored value overrides the seed for an uncontrolled key', () => { + entries.set('app', storedEntry({ appearance: 'dark' })); + const { container } = render( + + content + + ); + expect(themeElement(container)).toHaveAttribute('data-theme', 'dark'); + }); + + it('control is per key', () => { + entries.set('app', storedEntry({ appearance: 'dark', radius: 'full' })); + const { container } = render( + + content + + ); + const element = themeElement(container); + expect(element).toHaveAttribute('data-theme', 'light'); + expect(element).toHaveAttribute('data-radius', 'full'); + }); + + it('setValue never writes a controlled key', async () => { + const user = userEvent.setup(); + function Switcher() { + const { setValue } = useThemePreview(); + return ( + + ); + } + + const { container } = render( + + + + ); + await user.click(screen.getByRole('button')); + + expect(themeElement(container)).toHaveAttribute('data-theme', 'light'); + expect(themeElement(container)).toHaveAttribute('data-radius', 'large'); + expect(JSON.parse(entries.get('app') as string).settings).toEqual({ + radius: 'large' + }); + }); +}); + +// ─── Persistence ──────────────────────────────────────────────────────────── + +describe('persistence', () => { + it('does not touch storage without a persistKey', async () => { + const getItem = vi.spyOn(window.localStorage, 'getItem'); + const setItem = vi.spyOn(window.localStorage, 'setItem'); + const user = userEvent.setup(); + + function Switcher() { + const { setValue } = useThemePreview(); + return ( + + ); + } + + const { container } = render( + + + + ); + await user.click(screen.getByRole('button')); + + // The setting still applies, in memory. + expect(themeElement(container)).toHaveAttribute('data-theme', 'dark'); + expect(getItem).not.toHaveBeenCalled(); + expect(setItem).not.toHaveBeenCalled(); + }); + + it('emits no inline script without a persistKey', () => { + const { container } = render(content); + expect(container.querySelector('script')).toBeNull(); + }); + + it('emits an inline script for a persisted namespace', () => { + const { container } = render( + content + ); + const script = container.querySelector('script'); + expect(script).not.toBeNull(); + // First child, so it patches the opening tag already parsed above it. + expect(themeElement(container).firstChild).toBe(script); + }); + + it('omits the script when every persistable setting is controlled', () => { + const { container } = render( + + content + + ); + expect(container.querySelector('script')).toBeNull(); + }); + + it('omits the script when persist excludes everything', () => { + const { container } = render( + + content + + ); + expect(container.querySelector('script')).toBeNull(); + }); + + it('narrows a namespace with persist, keeping other settings in memory', async () => { + const user = userEvent.setup(); + function Switcher() { + const { setValue } = useThemePreview(); + return ( + + ); + } + + const { container } = render( + + + + ); + await user.click(screen.getByRole('button')); + + const element = themeElement(container); + expect(element).toHaveAttribute('data-theme', 'dark'); + expect(element).toHaveAttribute('data-radius', 'large'); + expect(JSON.parse(entries.get('app') as string).settings).toEqual({ + appearance: 'dark' + }); + }); + + it('keeps two themes sharing a namespace in step within one document', async () => { + const user = userEvent.setup(); + function Switcher() { + const { setValue } = useThemePreview(); + return ( + + ); + } + + const { container } = render( + <> + + + + + second + + + ); + + await user.click(screen.getByRole('button')); + + // The `storage` event does not fire here, so the in-document notification + // is what keeps the second theme in step. + expect(themeElement(container, 0)).toHaveAttribute('data-theme', 'dark'); + expect(themeElement(container, 1)).toHaveAttribute('data-theme', 'dark'); + }); + + it('synchronises across tabs through the storage event', () => { + const { container } = render( + content + ); + expect(themeElement(container)).toHaveAttribute('data-theme', 'light'); + + act(() => { + entries.set('app', storedEntry({ appearance: 'dark' })); + window.dispatchEvent(new Event('storage')); + }); + + expect(themeElement(container)).toHaveAttribute('data-theme', 'dark'); + }); + + it('reads storage on the first render under CSR', () => { + entries.set('app', storedEntry({ appearance: 'dark', radius: 'full' })); + const renders: string[] = []; + function Recorder() { + const { resolved } = useThemePreview(); + renders.push(`${resolved.appearance}/${resolved.radius}`); + return null; + } + + render( + + + + ); + + // No hydration under CSR, so the first render is already correct. + expect(renders[0]).toBe('dark/full'); + }); + + it('falls back to the seed for an unparseable entry', () => { + entries.set('app', 'not json at all'); + const { container } = render( + + content + + ); + expect(themeElement(container)).toHaveAttribute('data-theme', 'dark'); + }); +}); + +// ─── Resolution ───────────────────────────────────────────────────────────── + +describe('resolution', () => { + it('resolves `system` against the OS', () => { + installMatchMedia(true); + const { container } = render( + + + + ); + + expect(themeElement(container)).toHaveAttribute('data-theme', 'dark'); + const probe = readProbe(); + expect(probe.value.appearance).toBe('system'); + expect(probe.resolved.appearance).toBe('dark'); + }); + + it('follows the OS when it changes', () => { + const { container } = render(content); + expect(themeElement(container)).toHaveAttribute('data-theme', 'light'); + + media.setPrefersDark(true); + + expect(themeElement(container)).toHaveAttribute('data-theme', 'dark'); + }); + + it('pairs `auto` gray to the accent', () => { + const { container } = render( + + + + ); + + expect(themeElement(container)).toHaveAttribute('data-gray-color', 'mauve'); + expect(readProbe().value.grayColor).toBe('auto'); + expect(readProbe().resolved.grayColor).toBe('mauve'); + }); + + it('honours an explicit gray over the pairing', () => { + const { container } = render( + + content + + ); + expect(themeElement(container)).toHaveAttribute('data-gray-color', 'sage'); + }); + + it('reports the OS appearance whatever the setting is', () => { + installMatchMedia(true); + function SystemProbe() { + const { systemAppearance, resolved } = useThemePreview(); + return ( + {`${systemAppearance}/${resolved.appearance}`} + ); + } + render( + + + + ); + expect(screen.getByTestId('sys')).toHaveTextContent('dark/light'); + }); +}); + +// ─── The hook ─────────────────────────────────────────────────────────────── + +describe('useThemePreview', () => { + it('throws outside a provider', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => { + /* swallow React's expected error logging */ + }); + expect(() => render()).toThrow(/must be called inside/); + error.mockRestore(); + }); + + it('reaches the root provider from inside a scope', async () => { + const user = userEvent.setup(); + function RootSwitcher() { + const { root } = useThemePreview(); + return ( + + ); + } + + const { container } = render( + + + + + + ); + + await user.click(screen.getByRole('button')); + + expect(themeElement(container, 0)).toHaveAttribute('data-theme', 'dark'); + // The scope inherits the flipped appearance because it never set its own. + expect(themeElement(container, 1)).toHaveAttribute('data-theme', 'dark'); + }); + + it('reports the nearest theme as the root when there is only one', () => { + function RootProbe() { + const theme = useThemePreview(); + return ( + {theme.root.resolved.accentColor} + ); + } + render( + + + + ); + expect(screen.getByTestId('root')).toHaveTextContent('mint'); + }); +}); + +// ─── onValueChange ────────────────────────────────────────────────────────── + +describe('onValueChange', () => { + it('fires with the full next settings and the changed subset', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + + function Switcher() { + const { setValue } = useThemePreview(); + return ( + + ); + } + + render( + + + + ); + + expect(onValueChange).not.toHaveBeenCalled(); + await user.click(screen.getByRole('button')); + + expect(onValueChange).toHaveBeenCalledTimes(1); + const [next, changed] = onValueChange.mock.calls[0]; + expect(next.appearance).toBe('dark'); + expect(next.accentColor).toBe('indigo'); + expect(changed).toEqual({ appearance: 'dark' }); + }); +}); + +// ─── The render prop ──────────────────────────────────────────────────────── + +describe('render', () => { + it('merges the theme onto a caller-supplied element', () => { + const { container } = render( + }>content + ); + const element = themeElement(container); + expect(element.tagName).toBe('SECTION'); + expect(element).toHaveClass('page'); + expect(element).toHaveAttribute('data-theme', 'light'); + expect(container.querySelectorAll('.rs-theme')).toHaveLength(1); + }); + + it('accepts a function form', () => { + const { container } = render( +
}>content + ); + expect(themeElement(container).tagName).toBe('MAIN'); + }); + + it('keeps the theme children, including the inline script', () => { + const { container } = render( + supplied children are replaced} + > + mine + + ); + const element = themeElement(container); + expect(element.firstElementChild?.tagName).toBe('SCRIPT'); + expect(screen.getByTestId('mine')).toBeInTheDocument(); + expect(element).not.toHaveTextContent('supplied children are replaced'); + }); + + it('fires both refs', () => { + let ours: HTMLElement | null = null; + let theirs: unknown = null; + render( + { + ours = node; + }} + render={ +
{ + theirs = node; + }} + /> + } + > + content + + ); + expect(ours).not.toBeNull(); + expect(theirs).toBe(ours); + }); +}); + +// ─── Portals ──────────────────────────────────────────────────────────────── + +describe('the portal re-injector', () => { + function Portalled({ children }: { children?: ReactNode }) { + const theme = useThemeInjection(); + return ( +
+ {children} +
+ ); + } + + it('re-emits the inherited settings onto the portalled element', () => { + render( + + + + + + ); + + const portalled = screen.getByTestId('portalled'); + expect(portalled).toHaveClass('rs-theme'); + expect(portalled).toHaveAttribute('data-theme', 'dark'); + // The nearest scope wins: a portal used to render in the root's theme. + expect(portalled).toHaveAttribute('data-accent-color', 'orange'); + }); + + it('emits nothing outside a provider', () => { + render(); + const portalled = screen.getByTestId('portalled'); + expect(portalled).not.toHaveClass('rs-theme'); + expect(portalled).not.toHaveAttribute('data-theme'); + }); +}); + +// ─── Per-component radius ─────────────────────────────────────────────────── + +describe('the shared radius override', () => { + it('maps each level to its own class', () => { + expect(radiusClass('none')).toBeTruthy(); + expect(radiusClass('full')).toBeTruthy(); + expect(radiusClass('small')).not.toBe(radiusClass('large')); + }); + + it('returns nothing when the prop is unset', () => { + expect(radiusClass(undefined)).toBeUndefined(); + expect(radiusClass(null)).toBeUndefined(); + }); +}); + +// ─── Transitions ──────────────────────────────────────────────────────────── + +describe('disableTransitionOnChange', () => { + it('suppresses transitions across an appearance switch', async () => { + const user = userEvent.setup(); + function Switcher() { + const { setValue } = useThemePreview(); + return ( + + ); + } + + render( + + + + ); + + const before = document.head.querySelectorAll('style').length; + await act(async () => { + await user.click(screen.getByRole('button')); + }); + // The guard style is torn down on the next tick, so assert it ran and + // cleaned up rather than trying to observe it mid-flight. + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 5)); + }); + expect(document.head.querySelectorAll('style').length).toBe(before); + }); + + it('does not suppress anything on the first render', () => { + const before = document.head.querySelectorAll('style').length; + render(content); + expect(document.head.querySelectorAll('style').length).toBe(before); + }); +}); + +// ─── Mount reconciliation ─────────────────────────────────────────────────── + +describe('mount reconciliation', () => { + it('leaves the element alone when nothing drifted', () => { + const observed: string[] = []; + function Watcher() { + useEffect(() => { + observed.push('mounted'); + }, []); + return null; + } + const { container } = render( + + + + ); + expect(observed).toEqual(['mounted']); + expect(themeElement(container)).toHaveAttribute('data-theme', 'light'); + }); +}); diff --git a/packages/raystack/components/theme-preview/context.ts b/packages/raystack/components/theme-preview/context.ts new file mode 100644 index 000000000..7e64e1c76 --- /dev/null +++ b/packages/raystack/components/theme-preview/context.ts @@ -0,0 +1,61 @@ +'use client'; + +import { createContext, useContext } from 'react'; + +import type { + Appearance, + ResolvedThemeSettings, + ThemeSettings +} from './settings'; + +/** The theme, as read and driven from anywhere inside a provider. */ +export interface ThemeHandle { + /** Settings as set, `system` and `auto` included. */ + value: ThemeSettings; + /** Settings as applied, with `system` and `auto` resolved. */ + resolved: ResolvedThemeSettings; + /** Takes a partial settings object. Controlled keys are ignored. */ + setValue: (next: Partial) => void; + /** What the OS reports, whatever the current setting is. */ + systemAppearance: Appearance; +} + +export interface ThemeContextValue extends ThemeHandle { + /** Whether this theme owns the document's colour scheme. */ + isRoot: boolean; +} + +export const ThemeContext = createContext(null); +ThemeContext.displayName = 'ThemePreviewContext'; + +/** The root provider's handle, carried past every nested scope. */ +export const RootThemeContext = createContext(null); +RootThemeContext.displayName = 'RootThemePreviewContext'; + +export interface UseThemePreviewReturn extends ThemeHandle { + /** The same shape bound to the root provider. */ + root: ThemeHandle; +} + +/** + * Reads the nearest theme. Throws outside a provider rather than returning a + * no-op: every colour token is declared under `[data-theme]`, so a tree with + * no provider has no colours at all. + */ +export function useThemePreview(): UseThemePreviewReturn { + const context = useContext(ThemeContext); + const root = useContext(RootThemeContext); + if (!context) { + throw new Error( + '`useThemePreview` must be called inside a ``. Wrap your ' + + 'application in one — component colours are declared under the theme ' + + "element's attributes and do not exist without it." + ); + } + return { ...context, root: root ?? context }; +} + +/** The raw context, for internals that must tolerate its absence. */ +export function useThemeContextOrNull(): ThemeContextValue | null { + return useContext(ThemeContext); +} diff --git a/packages/raystack/components/theme-preview/index.tsx b/packages/raystack/components/theme-preview/index.tsx new file mode 100644 index 000000000..62a5d0c29 --- /dev/null +++ b/packages/raystack/components/theme-preview/index.tsx @@ -0,0 +1,46 @@ +export { + type ThemeContextValue, + type ThemeHandle, + type UseThemePreviewReturn, + useThemePreview +} from './context'; +export { type ThemeInjectionProps, useThemeInjection } from './portal'; +export { radiusClass, radiusClasses, radiusVariants } from './radius'; +export { createThemeScript, type ThemeScriptParams } from './script'; +export { + ACCENT_COLORS, + type AccentColor, + APPEARANCE_VALUES, + APPEARANCES, + type Appearance, + type AppearanceSetting, + DEFAULT_SETTINGS, + GRAY_COLOR_VALUES, + GRAY_COLORS, + GRAY_PAIRING, + type GrayColor, + type GrayColorSetting, + PANEL_BACKGROUNDS, + type PanelBackground, + RADII, + type Radius, + REDUCED_MOTION_VALUES, + type ReducedMotion, + type ResolvedThemeSettings, + resolveSettings, + SCALINGS, + type Scaling, + THEME_SETTING_KEYS, + type ThemeSettingKey, + type ThemeSettings +} from './settings'; +export { + ThemePreviewSwitcher, + type ThemePreviewSwitcherProps +} from './switcher'; +export { + ThemePreview, + type ThemePreviewProps, + type ThemeRenderProp +} from './theme-preview'; +export { useSystemAppearance } from './use-system-appearance'; diff --git a/packages/raystack/components/theme-preview/portal.ts b/packages/raystack/components/theme-preview/portal.ts new file mode 100644 index 000000000..fc3ab20ad --- /dev/null +++ b/packages/raystack/components/theme-preview/portal.ts @@ -0,0 +1,44 @@ +'use client'; + +import { type RefObject, useMemo } from 'react'; + +import { useThemeContextOrNull } from './context'; +import { settingsToAttributes, THEME_CLASS } from './settings'; + +/** Where a portalling component puts its content. */ +export type PortalContainer = + | HTMLElement + | ShadowRoot + | null + | RefObject; + +export interface ThemeInjectionProps { + className: string; + [attribute: string]: string; +} + +/** + * Theme values cross a portal through React context rather than the DOM, so a + * portalled element has to re-emit them. The returned props merge onto that + * element rather than adding a node — spread them first and pass `className` + * explicitly afterwards: + * + * ```tsx + * const theme = useThemeInjection(); + * + * ``` + * + * Returns `undefined` outside a provider, leaving such a portal unchanged. + */ +export function useThemeInjection(): ThemeInjectionProps | undefined { + const theme = useThemeContextOrNull(); + const resolved = theme?.resolved; + + return useMemo(() => { + if (!resolved) return undefined; + return { + className: THEME_CLASS, + ...settingsToAttributes(resolved) + }; + }, [resolved]); +} diff --git a/packages/raystack/components/theme-preview/radius.module.css b/packages/raystack/components/theme-preview/radius.module.css new file mode 100644 index 000000000..df74dae0d --- /dev/null +++ b/packages/raystack/components/theme-preview/radius.module.css @@ -0,0 +1,33 @@ +/* + * The shared per-component radius override. + * + * It affects only the element it is set on — never re-declaring the radius + * token scale, since custom properties inherit — and it does not compound with + * the theme radius: the value is re-derived from the component's own base step, + * `--rs-radius-step`, declared next to its `border-radius` rule. + * + * Selectors are doubled because the override class and the component's own + * class have equal specificity and CSS Modules ordering is not guaranteed. + */ + +.radius-none.radius-none { + border-radius: 0; +} + +.radius-small.radius-small { + border-radius: calc(var(--rs-radius-step, 4px) * var(--rs-scaling, 1) * 0.75); +} + +.radius-medium.radius-medium { + border-radius: calc(var(--rs-radius-step, 4px) * var(--rs-scaling, 1)); +} + +.radius-large.radius-large { + border-radius: calc(var(--rs-radius-step, 4px) * var(--rs-scaling, 1) * 1.5); +} + +/* A literal length, not `--rs-radius-pill`: that token is 0 unless the theme + itself is `full`, and a component override must not depend on the theme. */ +.radius-full.radius-full { + border-radius: 9999px; +} diff --git a/packages/raystack/components/theme-preview/radius.ts b/packages/raystack/components/theme-preview/radius.ts new file mode 100644 index 000000000..3a37d3339 --- /dev/null +++ b/packages/raystack/components/theme-preview/radius.ts @@ -0,0 +1,30 @@ +import styles from './radius.module.css'; +import type { Radius } from './settings'; + +/** + * The per-component `radius` override, as a cva variant so no component + * carries bespoke override CSS. + * + * ```ts + * const button = cva(styles['button'], { + * variants: { ...radiusVariants, size: { … } } + * }); + * ``` + */ +export const radiusClasses = { + none: styles['radius-none'], + small: styles['radius-small'], + medium: styles['radius-medium'], + large: styles['radius-large'], + full: styles['radius-full'] +} satisfies Record; + +/** Drop-in `variants` fragment for a cva definition. */ +export const radiusVariants = { radius: radiusClasses }; + +/** The class for a `radius` prop, or `undefined` when it is unset. */ +export function radiusClass(radius?: Radius | null): string | undefined { + return radius ? radiusClasses[radius] : undefined; +} + +export type { Radius }; diff --git a/packages/raystack/components/theme-preview/script.ts b/packages/raystack/components/theme-preview/script.ts new file mode 100644 index 000000000..8274c3d7d --- /dev/null +++ b/packages/raystack/components/theme-preview/script.ts @@ -0,0 +1,81 @@ +/** + * The pre-hydration inline script. Server-rendered HTML cannot know a + * client-side value, so this patches the theme element's attributes before + * first paint: it renders as the first child of that element and corrects its + * own parent, whose opening tag has already been parsed. Only a namespace's + * uncontrolled settings appear here, which usually means appearance alone. + */ + +import { + GRAY_PAIRING, + SETTING_ATTRIBUTES, + SETTING_VALUES, + STORAGE_VERSION, + SYSTEM_APPEARANCE_QUERY, + type ThemeSettingKey +} from './settings'; + +/** Identifies the theme element when `document.currentScript` is unavailable. */ +export const THEME_ID_ATTRIBUTE = 'data-rs-theme-id'; + +/** JSON that is safe to drop inside a `', keys: ['appearance'], + seed: LIGHT_SEED, elementId: 'x' }); expect(source).not.toContain(''); @@ -68,6 +97,7 @@ describe('the generated script', () => { const source = createThemeScript({ persistKey: 'app', keys: ['appearance'], + seed: LIGHT_SEED, elementId: 'rs-theme-abc' }) as string; @@ -83,6 +113,37 @@ describe('the generated script', () => { const source = createThemeScript({ persistKey: 'app', keys: ['appearance'], + seed: LIGHT_SEED, + elementId: 'rs-theme-abc' + }) as string; + + run(source, element); + + expect(element.getAttribute('data-theme')).toBe('dark'); + }); + + it('resolves a seeded `system` appearance against the OS when nothing is stored', () => { + // The common first visit: OS dark, storage empty, server rendered light. + installMatchMedia(true); + const element = themeElement(); + const source = createThemeScript({ + persistKey: 'app', + keys: ['appearance'], + seed: SYSTEM_SEED, + elementId: 'rs-theme-abc' + }) as string; + + run(source, element); + + expect(element.getAttribute('data-theme')).toBe('dark'); + }); + + it('resolves a seeded `system` appearance without a persistKey', () => { + installMatchMedia(true); + const element = themeElement(); + const source = createThemeScript({ + keys: [], + seed: SYSTEM_SEED, elementId: 'rs-theme-abc' }) as string; @@ -100,6 +161,7 @@ describe('the generated script', () => { const source = createThemeScript({ persistKey: 'app', keys: ['accentColor', 'grayColor'], + seed: LIGHT_SEED, elementId: 'rs-theme-abc' }) as string; @@ -109,11 +171,29 @@ describe('the generated script', () => { expect(element.getAttribute('data-gray-color')).toBe('mauve'); }); - it('leaves the server-rendered attribute when the entry is absent', () => { + it('resolves a seeded `auto` gray against a stored accent', () => { + // Gray is not persisted here, but its seed follows the accent, which is. + entries.set('app', storedEntry({ accentColor: 'orange' })); + const element = themeElement(); + const source = createThemeScript({ + persistKey: 'app', + keys: ['accentColor'], + seed: LIGHT_SEED, + elementId: 'rs-theme-abc' + }) as string; + + run(source, element); + + expect(element.getAttribute('data-accent-color')).toBe('orange'); + expect(element.getAttribute('data-gray-color')).toBe('mauve'); + }); + + it('leaves a pinned appearance alone when the entry is absent', () => { const element = themeElement(); const source = createThemeScript({ persistKey: 'app', keys: ['appearance'], + seed: LIGHT_SEED, elementId: 'rs-theme-abc' }) as string; @@ -128,6 +208,7 @@ describe('the generated script', () => { const source = createThemeScript({ persistKey: 'app', keys: ['appearance'], + seed: LIGHT_SEED, elementId: 'rs-theme-abc' }) as string; @@ -136,18 +217,21 @@ describe('the generated script', () => { expect(element.getAttribute('data-theme')).toBe('light'); }); - it('leaves the server-rendered attribute for an out-of-union value', () => { + it('falls back to the seed for an out-of-union stored value', () => { + // The React reader drops the bad field and lands on the seed too. + installMatchMedia(true); entries.set('app', storedEntry({ appearance: 'ultraviolet' })); const element = themeElement(); const source = createThemeScript({ persistKey: 'app', keys: ['appearance'], + seed: SYSTEM_SEED, elementId: 'rs-theme-abc' }) as string; run(source, element); - expect(element.getAttribute('data-theme')).toBe('light'); + expect(element.getAttribute('data-theme')).toBe('dark'); }); it('falls back to a selector when currentScript is unavailable', () => { @@ -156,6 +240,7 @@ describe('the generated script', () => { const source = createThemeScript({ persistKey: 'app', keys: ['appearance'], + seed: LIGHT_SEED, elementId: 'rs-theme-abc' }) as string; @@ -174,6 +259,7 @@ describe('the generated script', () => { const source = createThemeScript({ persistKey: 'app', keys: ['appearance'], + seed: LIGHT_SEED, elementId: 'rs-theme-abc' }) as string; diff --git a/packages/raystack/components/theme-preview/__tests__/ssr.test.tsx b/packages/raystack/components/theme-preview/__tests__/ssr.test.tsx index 25d7f107b..75b103758 100644 --- a/packages/raystack/components/theme-preview/__tests__/ssr.test.tsx +++ b/packages/raystack/components/theme-preview/__tests__/ssr.test.tsx @@ -61,13 +61,33 @@ describe('server rendering', () => { expect(theme.firstElementChild?.tagName).toBe('SCRIPT'); }); - it('emits no script and reads no storage when persistence is off', () => { + it('emits no script and reads no storage for a pinned appearance without persistence', () => { const getItem = vi.spyOn(window.localStorage, 'getItem'); - const html = renderToString(content); + const html = renderToString( + + content + + ); expect(html).not.toContain(' { + // Nothing stored and the OS is dark: the server's light must not survive. + installMatchMedia(true); + const html = renderToString( + content + ); + const container = document.createElement('div'); + container.innerHTML = html; + const theme = container.querySelector('.rs-theme') as HTMLElement; + expect(theme.getAttribute('data-theme')).toBe('light'); + + runInlineScript(theme); + + expect(theme.getAttribute('data-theme')).toBe('dark'); + }); + it('carries the CSP nonce onto the script', () => { const html = renderToString( diff --git a/packages/raystack/components/theme-preview/__tests__/store.test.ts b/packages/raystack/components/theme-preview/__tests__/store.test.ts index cb1e125a0..3d7d8af5f 100644 --- a/packages/raystack/components/theme-preview/__tests__/store.test.ts +++ b/packages/raystack/components/theme-preview/__tests__/store.test.ts @@ -146,6 +146,17 @@ describe('writeStoredSettings', () => { expect(notified).toBe(0); }); + it('leaves a newer-schema entry untouched and reports failure', () => { + entries.set('app', storedEntry({ appearance: 'dark', radius: 'full' }, 99)); + expect( + writeStoredSettings('app', ['accentColor'], { accentColor: 'mint' }) + ).toBe(false); + expect(JSON.parse(entries.get('app') as string)).toEqual({ + v: 99, + settings: { appearance: 'dark', radius: 'full' } + }); + }); + it('reports success, so the caller can trust storage', () => { expect( writeStoredSettings('app', ['appearance'], { appearance: 'dark' }) diff --git a/packages/raystack/components/theme-preview/__tests__/theme-preview.test.tsx b/packages/raystack/components/theme-preview/__tests__/theme-preview.test.tsx index 4c30ce8c5..f66bda0c5 100644 --- a/packages/raystack/components/theme-preview/__tests__/theme-preview.test.tsx +++ b/packages/raystack/components/theme-preview/__tests__/theme-preview.test.tsx @@ -2,10 +2,9 @@ import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { type ReactNode, useEffect } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; - +import { radiusClass } from '../../../shared/radius'; import { useThemePreview } from '../context'; import { useThemeInjection } from '../portal'; -import { radiusClass } from '../radius'; import type { ThemeSettings } from '../settings'; import { clearThemeStorageCache } from '../store'; import { ThemePreview } from '../theme-preview'; @@ -294,11 +293,23 @@ describe('persistence', () => { expect(setItem).not.toHaveBeenCalled(); }); - it('emits no inline script without a persistKey', () => { - const { container } = render(content); + it('emits no inline script when nothing needs patching', () => { + const { container } = render( + + content + + ); expect(container.querySelector('script')).toBeNull(); }); + it('emits a storage-free script for a `system` appearance without a persistKey', () => { + const { container } = render(content); + const script = container.querySelector('script'); + expect(script).not.toBeNull(); + expect(script?.textContent).not.toContain('localStorage'); + expect(script?.textContent).toContain('matchMedia'); + }); + it('emits an inline script for a persisted namespace', () => { const { container } = render( content @@ -322,15 +333,30 @@ describe('persistence', () => { expect(container.querySelector('script')).toBeNull(); }); - it('omits the script when persist excludes everything', () => { + it('omits the script when persist excludes everything and appearance is pinned', () => { const { container } = render( - + content ); expect(container.querySelector('script')).toBeNull(); }); + it('reads no storage when persist excludes everything but appearance is system', () => { + const { container } = render( + + content + + ); + const script = container.querySelector('script'); + expect(script).not.toBeNull(); + expect(script?.textContent).not.toContain('localStorage'); + }); + it('narrows a namespace with persist, keeping other settings in memory', async () => { const user = userEvent.setup(); function Switcher() { diff --git a/packages/raystack/components/theme-preview/index.tsx b/packages/raystack/components/theme-preview/index.tsx index 034d61e70..8dc79fde1 100644 --- a/packages/raystack/components/theme-preview/index.tsx +++ b/packages/raystack/components/theme-preview/index.tsx @@ -4,37 +4,22 @@ export { type UseThemePreviewReturn, useThemePreview } from './context'; -export { - type PortalContainer, - type ThemeInjectionProps, - useThemeInjection -} from './portal'; -export { radiusClass, radiusClasses, radiusVariants } from './radius'; +export { type ThemeInjectionProps, useThemeInjection } from './portal'; export { createThemeScript, type ThemeScriptParams } from './script'; export { - ACCENT_COLORS, type AccentColor, - APPEARANCE_VALUES, - APPEARANCES, type Appearance, type AppearanceSetting, - DEFAULT_SETTINGS, - GRAY_COLOR_VALUES, - GRAY_COLORS, - GRAY_PAIRING, type GrayColor, type GrayColorSetting, - PANEL_BACKGROUNDS, type PanelBackground, - RADII, type Radius, - REDUCED_MOTION_VALUES, type ReducedMotion, type ResolvedThemeSettings, - resolveSettings, - SCALINGS, type Scaling, + THEME_DEFAULT_SETTINGS, THEME_SETTING_KEYS, + THEME_SETTING_VALUES, type ThemeSettingKey, type ThemeSettings } from './settings'; diff --git a/packages/raystack/components/theme-preview/portal.ts b/packages/raystack/components/theme-preview/portal.ts index d69a95722..cf3264b96 100644 --- a/packages/raystack/components/theme-preview/portal.ts +++ b/packages/raystack/components/theme-preview/portal.ts @@ -1,17 +1,10 @@ 'use client'; -import { type RefObject, useMemo } from 'react'; +import { useMemo } from 'react'; import { useThemeContextOrNull } from './context'; import { settingsToAttributes, THEME_CLASS } from './settings'; -/** Where a portalling component puts its content. */ -export type PortalContainer = - | HTMLElement - | ShadowRoot - | null - | RefObject; - export interface ThemeInjectionProps { className: string; [attribute: string]: string; diff --git a/packages/raystack/components/theme-preview/script.ts b/packages/raystack/components/theme-preview/script.ts index 11a0f8173..88ddad857 100644 --- a/packages/raystack/components/theme-preview/script.ts +++ b/packages/raystack/components/theme-preview/script.ts @@ -1,15 +1,19 @@ /** * Pre-hydration script. Rendered as the theme element's first child, it patches - * its own parent from storage before first paint. + * its own parent before first paint with what the server could not know: the + * stored value of each persisted key, and the OS answer for a `system` + * appearance that has nothing stored. */ import { GRAY_PAIRING, SETTING_ATTRIBUTES, - SETTING_VALUES, STORAGE_VERSION, SYSTEM_APPEARANCE_QUERY, - type ThemeSettingKey + THEME_SETTING_KEYS, + THEME_SETTING_VALUES, + type ThemeSettingKey, + type ThemeSettings } from './settings'; /** Identifies the theme element when `document.currentScript` is unavailable. */ @@ -26,26 +30,55 @@ function inlineJson(value: unknown): string { } export interface ThemeScriptParams { - persistKey: string; - /** Uncontrolled keys the namespace covers; controlled keys are excluded. */ + /** Storage namespace. Without one the script reads no storage at all. */ + persistKey?: string; + /** Keys read from storage: the namespace's uncontrolled, persisted settings. */ keys: readonly ThemeSettingKey[]; + /** + * The settings the server rendered from. A key with nothing stored falls + * back to this, which is how a seeded `system` appearance resolves against + * the OS before first paint. + */ + seed: ThemeSettings; /** Fallback selector target, in case `document.currentScript` is absent. */ elementId: string; } /** Builds the script source, or `null` when there is nothing for it to do. */ export function createThemeScript(params: ThemeScriptParams): string | null { - const { persistKey, keys, elementId } = params; - if (keys.length === 0) return null; + const { persistKey, keys, seed, elementId } = params; + const stored = persistKey ? keys : []; - // From the shared config, so the script and the React reader cannot drift. - const map = keys.map(key => [ - key, - SETTING_ATTRIBUTES[key], - SETTING_VALUES[key] - ]); + // [key, attribute, legalValues, seededValue, readsStorage]. A key takes part + // when storage may hold it, or when its seed needs the browser to resolve it. + const map: [string, string, readonly string[], string, boolean][] = []; + for (const key of THEME_SETTING_KEYS) { + const fromStore = stored.includes(key); + const needsBrowser = + (key === 'appearance' && seed.appearance === 'system') || + (key === 'grayColor' && + seed.grayColor === 'auto' && + stored.includes('accentColor')); + if (!fromStore && !needsBrowser) continue; + map.push([ + key, + SETTING_ATTRIBUTES[key], + THEME_SETTING_VALUES[key], + seed[key], + fromStore + ]); + } + if (map.length === 0) return null; const fallbackSelector = `[${THEME_ID_ATTRIBUTE}="${elementId}"]`; + // Only when some key actually reads storage; a bare `system` seed never does. + const readStorage = + persistKey && stored.length > 0 + ? `try{var r=localStorage.getItem(${inlineJson(persistKey)});` + + `if(r){var o=JSON.parse(r);` + + `if(o&&typeof o==="object"&&typeof o.v==="number"&&o.v<=${STORAGE_VERSION}` + + `&&o.settings&&typeof o.settings==="object")s=o.settings}}catch(t){}` + : ''; return ( `!function(){try{` + @@ -53,14 +86,13 @@ export function createThemeScript(params: ThemeScriptParams): string | null { `p=(e&&e.parentElement)||d.querySelector(${inlineJson(fallbackSelector)});` + `if(!p)return;` + `var m=${inlineJson(map)},g=${inlineJson(GRAY_PAIRING)},s={};` + - `try{var r=localStorage.getItem(${inlineJson(persistKey)});` + - `if(r){var o=JSON.parse(r);` + - `if(o&&typeof o==="object"&&typeof o.v==="number"&&o.v<=${STORAGE_VERSION}` + - `&&o.settings&&typeof o.settings==="object")s=o.settings}}catch(t){}` + + readStorage + `for(var i=0;i=0)v=s[k];` + + `if(l.indexOf(v)<0)continue;` + `if(k==="appearance"&&v==="system")` + `v=matchMedia(${inlineJson(SYSTEM_APPEARANCE_QUERY)}).matches?"dark":"light";` + // Key order puts `accentColor` first, so the accent is already patched. diff --git a/packages/raystack/components/theme-preview/settings.ts b/packages/raystack/components/theme-preview/settings.ts index 9b9fa3f5a..7638af8b2 100644 --- a/packages/raystack/components/theme-preview/settings.ts +++ b/packages/raystack/components/theme-preview/settings.ts @@ -1,11 +1,12 @@ /** Single source of truth for setting names, defaults, attributes and values. */ +import { RADII, type Radius } from '../../shared/radius'; + export const APPEARANCES = ['light', 'dark'] as const; export const APPEARANCE_VALUES = ['light', 'dark', 'system'] as const; export const ACCENT_COLORS = ['indigo', 'orange', 'mint'] as const; export const GRAY_COLORS = ['gray', 'mauve', 'slate', 'sage'] as const; export const GRAY_COLOR_VALUES = [...GRAY_COLORS, 'auto'] as const; -export const RADII = ['none', 'small', 'medium', 'large', 'full'] as const; export const SCALINGS = ['0.9', '0.95', '1', '1.05', '1.1'] as const; export const PANEL_BACKGROUNDS = ['solid', 'translucent'] as const; export const REDUCED_MOTION_VALUES = ['true', 'false', 'system'] as const; @@ -17,7 +18,7 @@ export type AccentColor = (typeof ACCENT_COLORS)[number]; /** Gray with `auto` resolved. */ export type GrayColor = (typeof GRAY_COLORS)[number]; export type GrayColorSetting = (typeof GRAY_COLOR_VALUES)[number]; -export type Radius = (typeof RADII)[number]; +export { RADII, type Radius }; export type Scaling = (typeof SCALINGS)[number]; export type PanelBackground = (typeof PANEL_BACKGROUNDS)[number]; export type ReducedMotion = (typeof REDUCED_MOTION_VALUES)[number]; @@ -52,7 +53,7 @@ export const THEME_SETTING_KEYS = [ 'reducedMotion' ] as const satisfies readonly ThemeSettingKey[]; -export const DEFAULT_SETTINGS: ThemeSettings = { +export const THEME_DEFAULT_SETTINGS: ThemeSettings = { appearance: 'system', accentColor: 'indigo', grayColor: 'auto', @@ -73,7 +74,7 @@ export const SETTING_ATTRIBUTES = { } as const satisfies Record; /** Legal values per setting. */ -export const SETTING_VALUES = { +export const THEME_SETTING_VALUES = { appearance: APPEARANCE_VALUES, accentColor: ACCENT_COLORS, grayColor: GRAY_COLOR_VALUES, @@ -106,7 +107,7 @@ export function coerceSetting( key: K, value: unknown ): ThemeSettings[K] | undefined { - const allowed: readonly string[] = SETTING_VALUES[key]; + const allowed: readonly string[] = THEME_SETTING_VALUES[key]; return typeof value === 'string' && allowed.includes(value) ? (value as ThemeSettings[K]) : undefined; diff --git a/packages/raystack/components/theme-preview/store.ts b/packages/raystack/components/theme-preview/store.ts index 1fde3fe56..f1e74a5b4 100644 --- a/packages/raystack/components/theme-preview/store.ts +++ b/packages/raystack/components/theme-preview/store.ts @@ -37,23 +37,31 @@ function readRaw(persistKey: string): string | null { } } -// Tolerates any stored shape. -function parseEntry(raw: string | null): Record { - if (!raw) return {}; +// Tolerates any stored shape; `null` when nothing usable is there. +function readEntry(raw: string | null): StoredEntry | null { + if (!raw) return null; let parsed: unknown; try { parsed = JSON.parse(raw); } catch { // Includes the legacy bare theme name. - return {}; + return null; } if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - return {}; + return null; } const entry = parsed as Partial; - if (typeof entry.v !== 'number' || entry.v > STORAGE_VERSION) return {}; - if (typeof entry.settings !== 'object' || entry.settings === null) return {}; - return entry.settings as Record; + if (typeof entry.v !== 'number') return null; + if (typeof entry.settings !== 'object' || entry.settings === null) + return null; + return { v: entry.v, settings: entry.settings as Record }; +} + +// A newer schema reads as empty: this build cannot vouch for its fields. +function parseEntry(raw: string | null): Record { + const entry = readEntry(raw); + if (!entry || entry.v > STORAGE_VERSION) return {}; + return entry.settings; } /** The client snapshot, identity-stable while the stored string is unchanged. */ @@ -78,7 +86,7 @@ export function readServerSettings(): Partial { /** * Merges `patch` into the namespace for the `allowed` keys only. Returns - * `false` when storage refused the write. + * `false` when storage refused the write or already holds a newer schema. */ export function writeStoredSettings( persistKey: string, @@ -86,7 +94,10 @@ export function writeStoredSettings( patch: Partial ): boolean { if (typeof window === 'undefined') return false; - const settings = parseEntry(readRaw(persistKey)); + const existing = readEntry(readRaw(persistKey)); + // Never downgrade: rewriting a newer entry would destroy what that build owns. + if (existing && existing.v > STORAGE_VERSION) return false; + const settings = existing?.settings ?? {}; let changed = false; for (const key of allowed) { const next = patch[key]; diff --git a/packages/raystack/components/theme-preview/theme-preview.tsx b/packages/raystack/components/theme-preview/theme-preview.tsx index 8e87c4be7..b191c6c73 100644 --- a/packages/raystack/components/theme-preview/theme-preview.tsx +++ b/packages/raystack/components/theme-preview/theme-preview.tsx @@ -29,13 +29,13 @@ import { import { createThemeScript, THEME_ID_ATTRIBUTE } from './script'; import { assignSetting, - DEFAULT_SETTINGS, ROOT_ATTRIBUTE, resolveSettings, - SETTING_VALUES, settingsToAttributes, THEME_CLASS, + THEME_DEFAULT_SETTINGS, THEME_SETTING_KEYS, + THEME_SETTING_VALUES, type ThemeSettingKey, type ThemeSettings } from './settings'; @@ -56,7 +56,7 @@ export interface ThemePreviewProps extends Omit, 'defaultValue' | 'onChange'> { /** Seeds uncontrolled keys. A stored user choice overrides it. */ defaultValue?: Partial; - /** Per-key control. Wins over storage, never persisted or scripted. */ + /** Per-key control. Wins over storage and is never persisted. */ value?: Partial; /** * Fires when `setValue` requests a change. Controlled keys are reported but @@ -115,7 +115,7 @@ function resolvePrecedence( local: Partial, seed: Partial | undefined ): ThemeSettings { - const next = { ...(inherited ?? DEFAULT_SETTINGS) }; + const next = { ...(inherited ?? THEME_DEFAULT_SETTINGS) }; for (const key of THEME_SETTING_KEYS) { const value = controlled?.[key] ?? stored[key] ?? local[key] ?? seed?.[key]; if (value !== undefined) assignSetting(next, key, value); @@ -279,7 +279,7 @@ export function ThemePreview({ for (const key of THEME_SETTING_KEYS) { const candidate = next[key]; if (candidate === undefined) continue; - const allowed: readonly string[] = SETTING_VALUES[key]; + const allowed: readonly string[] = THEME_SETTING_VALUES[key]; if (!allowed.includes(candidate) || current[key] === candidate) { continue; } @@ -345,12 +345,20 @@ export function ThemePreview({ nonce ); + // What the server rendered from, before storage is consulted. + const seed = useStableSettings( + resolvePrecedence( + parent?.value, + value, + EMPTY_PATCH, + EMPTY_PATCH, + defaultValue + ) + ); const script = useMemo( () => - persistKey - ? createThemeScript({ persistKey, keys: persistedKeys, elementId }) - : null, - [persistKey, persistedKeys, elementId] + createThemeScript({ persistKey, keys: persistedKeys, seed, elementId }), + [persistKey, persistedKeys, seed, elementId] ); const elementRef = useRef(null); diff --git a/packages/raystack/components/toast/toast-provider.tsx b/packages/raystack/components/toast/toast-provider.tsx index 14b4e7e48..57db385aa 100644 --- a/packages/raystack/components/toast/toast-provider.tsx +++ b/packages/raystack/components/toast/toast-provider.tsx @@ -2,10 +2,7 @@ import { Toast as ToastPrimitive } from '@base-ui/react'; import { cx } from 'class-variance-authority'; -import { - type PortalContainer, - useThemeInjection -} from '../theme-preview/portal'; +import { useThemeInjection } from '../theme-preview/portal'; import styles from './toast.module.css'; import { toastManager as defaultToastManager, @@ -34,8 +31,6 @@ export interface ToastProviderProps * `Toast.createToastManager()` to scope toasts to this provider. */ toastManager?: ToastManager; - /** Portals the viewport into this element instead of `document.body`. */ - container?: PortalContainer; } function ToastList({ position }: { position: ToastPosition }) { @@ -48,7 +43,6 @@ function ToastList({ position }: { position: ToastPosition }) { export function ToastProvider({ position = 'bottom-right', toastManager = defaultToastManager, - container, children, ...props }: ToastProviderProps) { @@ -56,7 +50,7 @@ export function ToastProvider({ return ( {children} - + + ReactNode); - /** Portals into this element instead of `document.body`. */ - container?: PortalContainer; /** Corner radius for this card only. Overrides the theme's `radius`. */ radius?: Radius; } @@ -53,7 +47,6 @@ export function TourContent({ showArrow = false, className, style, - container, radius, children }: TourContentProps) { @@ -116,7 +109,7 @@ export function TourContent({ if (eventDetails.reason === 'escape-key') actions.stop(); }} > - + Promise; // Return success export const useCopyToClipboard = () => { - const [copiedText, setCopiedText] = useState(""); + const [copiedText, setCopiedText] = useState(''); - const copy: CopyFn = async (text) => { + const copy: CopyFn = async text => { if (!navigator?.clipboard) { - console.warn("Clipboard not supported"); + console.warn('Clipboard not supported'); return false; } @@ -17,8 +17,8 @@ export const useCopyToClipboard = () => { setCopiedText(text); return true; } catch (error) { - console.warn("Copy failed", error); - setCopiedText(""); + console.warn('Copy failed', error); + setCopiedText(''); return false; } }; diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index 384fafb66..30bdad450 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -145,37 +145,29 @@ export { Tabs } from './components/tabs'; export { Text } from './components/text'; export { TextArea } from './components/text-area'; export { - ACCENT_COLORS, type AccentColor, - APPEARANCE_VALUES, type Appearance, type AppearanceSetting, - DEFAULT_SETTINGS, - GRAY_COLOR_VALUES, - GRAY_COLORS, - GRAY_PAIRING, + createThemeScript, type GrayColor, type GrayColorSetting, - PANEL_BACKGROUNDS, type PanelBackground, - type PortalContainer, - RADII, type Radius, - REDUCED_MOTION_VALUES, type ReducedMotion, type ResolvedThemeSettings, - radiusClass, - radiusClasses, - radiusVariants, - SCALINGS, type Scaling, + THEME_DEFAULT_SETTINGS, THEME_SETTING_KEYS, + THEME_SETTING_VALUES, + type ThemeContextValue, type ThemeHandle, + type ThemeInjectionProps, ThemePreview, type ThemePreviewProps, ThemePreviewSwitcher, type ThemePreviewSwitcherProps, type ThemeRenderProp, + type ThemeScriptParams, type ThemeSettingKey, type ThemeSettings, type UseThemePreviewReturn, @@ -215,3 +207,4 @@ export { } from './icons/create-icon'; export * from './icons/icons'; export type { IconName } from './icons/types'; +export { radiusClass } from './shared/radius'; diff --git a/packages/raystack/package.json b/packages/raystack/package.json index 66804e8e6..96f597c4a 100644 --- a/packages/raystack/package.json +++ b/packages/raystack/package.json @@ -1,6 +1,11 @@ { "name": "@raystack/apsara", "version": "0.48.0", + "browserslist": [ + "defaults", + "safari >= 15", + "ios_saf >= 15" + ], "types": "dist/index.d.ts", "sideEffects": false, "engines": { @@ -96,6 +101,7 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitest/ui": "^3.2.4", + "autoprefixer": "^10.4.21", "dotenv": "^17.2.2", "identity-obj-proxy": "^3.0.0", "jsdom": "^26.1.0", diff --git a/packages/raystack/rollup.config.mjs b/packages/raystack/rollup.config.mjs index fe1156378..f89301303 100644 --- a/packages/raystack/rollup.config.mjs +++ b/packages/raystack/rollup.config.mjs @@ -1,6 +1,7 @@ import commonjs from '@rollup/plugin-commonjs'; import { nodeResolve } from '@rollup/plugin-node-resolve'; import typescript from '@rollup/plugin-typescript'; +import autoprefixer from 'autoprefixer'; import postcssImport from 'postcss-import'; import nodeExternals from 'rollup-plugin-node-externals'; import postcss from 'rollup-plugin-postcss'; @@ -22,7 +23,7 @@ const createPlugins = ({ rootDir, declarationDir }) => [ nodeResolve(), commonjs(), postcss({ - plugins: [postcssImport()], + plugins: [postcssImport(), autoprefixer()], extract: 'style.css', minimize: true, autoModules: true, // Auto process files ending with .module.css diff --git a/packages/raystack/shared/radius/index.ts b/packages/raystack/shared/radius/index.ts new file mode 100644 index 000000000..af2ea4009 --- /dev/null +++ b/packages/raystack/shared/radius/index.ts @@ -0,0 +1 @@ +export * from './radius'; diff --git a/packages/raystack/components/theme-preview/radius.module.css b/packages/raystack/shared/radius/radius.module.css similarity index 100% rename from packages/raystack/components/theme-preview/radius.module.css rename to packages/raystack/shared/radius/radius.module.css diff --git a/packages/raystack/components/theme-preview/radius.ts b/packages/raystack/shared/radius/radius.ts similarity index 83% rename from packages/raystack/components/theme-preview/radius.ts rename to packages/raystack/shared/radius/radius.ts index de74a18e3..890ad8557 100644 --- a/packages/raystack/components/theme-preview/radius.ts +++ b/packages/raystack/shared/radius/radius.ts @@ -1,5 +1,7 @@ import styles from './radius.module.css'; -import type { Radius } from './settings'; + +export const RADII = ['none', 'small', 'medium', 'large', 'full'] as const; +export type Radius = (typeof RADII)[number]; /** Per-component `radius` override, spread into a cva `variants` block. */ export const radiusClasses = { @@ -16,5 +18,3 @@ export const radiusVariants = { radius: radiusClasses }; export function radiusClass(radius?: Radius | null): string | undefined { return radius ? radiusClasses[radius] : undefined; } - -export type { Radius }; diff --git a/packages/raystack/style.css b/packages/raystack/style.css index 9e65bd451..dd2c414a1 100644 --- a/packages/raystack/style.css +++ b/packages/raystack/style.css @@ -78,7 +78,7 @@ --radius-5: 12px; --radius-6: 16px; --radius-full: 800px; - + --pd-2: 2px; --pd-4: 4px; --pd-6: 6px; @@ -106,12 +106,15 @@ --shadow-xs: 0px 1px 2px 0px rgba(16, 24, 40, 0.06); --shadow-sm: 0px 1px 4px 0px rgba(0, 0, 0, 0.09); - --shadow-md: 0px 12px 16px -4px rgba(16, 24, 40, 0.08), - 0px 4px 6px -2px rgba(16, 24, 40, 0.03); - --shadow-lg: 0px 20px 24px -4px rgba(16, 24, 40, 0.08), - 0px 8px 8px -4px rgba(16, 24, 40, 0.03); - --shadow-xl: 0px 20px 24px -4px rgba(16, 24, 40, 0.08), - 0px 8px 8px -4px rgba(16, 24, 40, 0.03); + --shadow-md: + 0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px + rgba(16, 24, 40, 0.03); + --shadow-lg: + 0px 20px 24px -4px rgba(16, 24, 40, 0.08), 0px 8px 8px -4px + rgba(16, 24, 40, 0.03); + --shadow-xl: + 0px 20px 24px -4px rgba(16, 24, 40, 0.08), 0px 8px 8px -4px + rgba(16, 24, 40, 0.03); } html[data-theme="dark"] { diff --git a/packages/raystack/styles/primitives/appearance.css b/packages/raystack/styles/primitives/appearance.css index b28d63b61..17c8222a7 100644 --- a/packages/raystack/styles/primitives/appearance.css +++ b/packages/raystack/styles/primitives/appearance.css @@ -4,9 +4,9 @@ * All variables must be used from colors.css file only. */ -/* Smooth theme switch transition — applies regardless of which theme is active */ -[data-theme="light"], -[data-theme="dark"] { +/* Smooth theme switch transition. Zero specificity like every other theme rule, + so a component's own `transition` shorthand always wins over it. */ +:where([data-theme="light"], [data-theme="dark"]) { transition: background-color var(--rs-duration-slow) ease, color var(--rs-duration-slow) ease; diff --git a/packages/raystack/styles/typography.css b/packages/raystack/styles/typography.css index 24832c195..1bfd8a67a 100644 --- a/packages/raystack/styles/typography.css +++ b/packages/raystack/styles/typography.css @@ -11,9 +11,9 @@ /* Font Families */ --rs-font-title: var(--rs-font-inter), sans-serif; --rs-font-body: var(--rs-font-inter), sans-serif; - /* JetBrains Mono before Menlo, or the imported face never renders on macOS. */ + /* Menlo first by design: the system mono on macOS, JetBrains Mono elsewhere. */ --rs-font-mono: - var(--rs-font-jetbrains-mono), var(--rs-font-menlo), monospace; + var(--rs-font-menlo), var(--rs-font-jetbrains-mono), monospace; /* Font Weights */ --rs-font-weight-regular: 400; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f7baba41..3a08dce65 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -260,6 +260,9 @@ importers: '@vitest/ui': specifier: ^3.2.4 version: 3.2.4(vitest@3.2.4) + autoprefixer: + specifier: ^10.4.21 + version: 10.6.1(postcss@8.4.24) dotenv: specifier: ^17.2.2 version: 17.2.2 @@ -2637,6 +2640,13 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + autoprefixer@10.6.1: + resolution: {integrity: sha512-cL1Qz6ADZhcEbny/8HPfe99J6HhNoYtpX2LFLIbhgGE7Q1hlQVkYFdetDN7Id3KiQxhDrHwzlHr/YQCnZ8+xSA==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -2657,6 +2667,11 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.11.24: + resolution: {integrity: sha512-hYrgxie335U08WqICoGqKRzV1HFXv6zdxwJE4ekCb80CM9a0SVVsN4QPwT67RraRo+9h8IATk6uxHJw7QSkdOg==} + engines: {node: '>=6.0.0'} + hasBin: true + binaryextensions@6.11.0: resolution: {integrity: sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw==} engines: {node: '>=4'} @@ -2693,6 +2708,11 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + browserslist@4.29.0: + resolution: {integrity: sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} @@ -2742,6 +2762,9 @@ packages: caniuse-lite@1.0.30001701: resolution: {integrity: sha512-faRs/AW3jA9nTwmJBSO1PQ6L/EOgsB5HMQQq4iCu5zhPgVVgO/pZRHlmatwijZKetFw8/Pr4q6dEN8sJuq8qTw==} + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -3169,6 +3192,9 @@ packages: electron-to-chromium@1.4.827: resolution: {integrity: sha512-VY+J0e4SFcNfQy19MEoMdaIcZLmDCprqvBtkii1WTCTQHpRvf5N8+3kTYCgL/PcntvwQvmMJWTuDPsq+IlhWKQ==} + electron-to-chromium@1.5.430: + resolution: {integrity: sha512-e1QEj72Y4zd8RlNZVmoTg+iCOSVwpk05IOiiQwdrkwCSVlZfPthevErhE+nckGd2YbsXfp1SkisznhGVIXP2NQ==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -3237,6 +3263,10 @@ packages: resolution: {integrity: sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==} engines: {node: '>=6'} + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + escape-string-regexp@1.0.5: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} @@ -3370,6 +3400,9 @@ packages: resolution: {integrity: sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==} engines: {node: '>= 6'} + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -4537,6 +4570,10 @@ packages: node-releases@2.0.14: resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==} + node-releases@2.0.56: + resolution: {integrity: sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==} + engines: {node: '>=18'} + node-sarif-builder@2.0.3: resolution: {integrity: sha512-Pzr3rol8fvhG/oJjIq2NTVB0vmdNNlz22FENhhPojYRZ4/ee08CfK4YuKmuL54V9MLhI1kpzxfOJ/63LzmZzDg==} engines: {node: '>=14'} @@ -5974,6 +6011,12 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + update-browserslist-db@1.3.3: + resolution: {integrity: sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + update-check@1.5.4: resolution: {integrity: sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ==} @@ -8580,6 +8623,15 @@ snapshots: asynckit@0.4.0: {} + autoprefixer@10.6.1(postcss@8.4.24): + dependencies: + browserslist: 4.29.0 + caniuse-lite: 1.0.30001810 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.4.24 + postcss-value-parser: 4.2.0 + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.0.0 @@ -8597,6 +8649,8 @@ snapshots: base64-js@1.5.1: {} + baseline-browser-mapping@2.11.24: {} + binaryextensions@6.11.0: dependencies: editions: 6.21.0 @@ -8646,6 +8700,14 @@ snapshots: node-releases: 2.0.14 update-browserslist-db: 1.1.0(browserslist@4.23.2) + browserslist@4.29.0: + dependencies: + baseline-browser-mapping: 2.11.24 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.430 + node-releases: 2.0.56 + update-browserslist-db: 1.3.3(browserslist@4.29.0) + buffer-crc32@0.2.13: {} buffer-equal-constant-time@1.0.1: {} @@ -8700,6 +8762,8 @@ snapshots: caniuse-lite@1.0.30001701: {} + caniuse-lite@1.0.30001810: {} + ccount@2.0.1: {} chai@5.3.3: @@ -9183,6 +9247,8 @@ snapshots: electron-to-chromium@1.4.827: {} + electron-to-chromium@1.5.430: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -9306,6 +9372,8 @@ snapshots: escalade@3.1.2: {} + escalade@3.2.0: {} + escape-string-regexp@1.0.5: {} escape-string-regexp@5.0.0: {} @@ -9444,6 +9512,8 @@ snapshots: combined-stream: 1.0.8 mime-types: 2.1.35 + fraction.js@5.3.4: {} + fs-constants@1.0.0: optional: true @@ -10988,6 +11058,8 @@ snapshots: node-releases@2.0.14: {} + node-releases@2.0.56: {} + node-sarif-builder@2.0.3: dependencies: '@types/sarif': 2.1.7 @@ -12657,6 +12729,12 @@ snapshots: escalade: 3.1.2 picocolors: 1.1.1 + update-browserslist-db@1.3.3(browserslist@4.29.0): + dependencies: + browserslist: 4.29.0 + escalade: 3.2.0 + picocolors: 1.1.1 + update-check@1.5.4: dependencies: registry-auth-token: 3.3.2 From 62cb7a9daf8206ca7f12803d178e41ce38c0e9b5 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 17 Sep 2026 23:06:18 +0530 Subject: [PATCH 04/22] fix(theme): Switch and Slider follow the theme radius 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. --- apps/www/src/content/docs/theme/preview/demo.ts | 1 + apps/www/src/content/docs/theme/preview/index.mdx | 2 +- packages/raystack/CHANGELOG.md | 3 +++ packages/raystack/components/slider/slider.module.css | 8 +++++--- packages/raystack/components/switch/switch.module.css | 8 ++++++-- packages/raystack/styles/theme.css | 11 ++++++++++- 6 files changed, 26 insertions(+), 7 deletions(-) diff --git a/apps/www/src/content/docs/theme/preview/demo.ts b/apps/www/src/content/docs/theme/preview/demo.ts index a42baa0f1..994b4ab8d 100644 --- a/apps/www/src/content/docs/theme/preview/demo.ts +++ b/apps/www/src/content/docs/theme/preview/demo.ts @@ -55,6 +55,7 @@ export const radiusDemo = { {radius} + ))} diff --git a/apps/www/src/content/docs/theme/preview/index.mdx b/apps/www/src/content/docs/theme/preview/index.mdx index 3fe84639f..a54c4535d 100644 --- a/apps/www/src/content/docs/theme/preview/index.mdx +++ b/apps/www/src/content/docs/theme/preview/index.mdx @@ -76,7 +76,7 @@ Font families are deliberately not a setting. They are customised through CSS va ### Radius -Radius is a factor applied to a fixed base scale, so `radius="small"` means the same thing in every configuration. +Radius is a factor applied to a fixed base scale, so `radius="small"` means the same thing in every configuration. Pill shapes follow two further rules, as in Radix Themes: controls such as `Button` and `Chip` become pills only at `full`, while round controls such as `Switch` and the `Slider` track stay round from `medium` up and square off at `none` and `small`. Surfaces such as `Dialog` and `Popover` never become pills, and `Radio` is always round. diff --git a/packages/raystack/CHANGELOG.md b/packages/raystack/CHANGELOG.md index 37645c307..299f53f06 100644 --- a/packages/raystack/CHANGELOG.md +++ b/packages/raystack/CHANGELOG.md @@ -95,6 +95,9 @@ includes the migration guide. backdrop-filter tokens. - **Per-component `radius`** on Button, IconButton, Badge, Callout, Chip, Input, TextArea, Image, Avatar and the portalled `Content` sub-components. +- **Round controls follow the theme radius.** `Switch` and the `Slider` track + and thumb are round from `medium` up and square at `none` and `small`, via + a new `--rs-radius-thumb` token. `Radio` stays round. - **`style-no-fonts.css`** is published alongside `style.css` for consumers that self-host or already load the fonts. diff --git a/packages/raystack/components/slider/slider.module.css b/packages/raystack/components/slider/slider.module.css index c87aeba5d..cec49e598 100644 --- a/packages/raystack/components/slider/slider.module.css +++ b/packages/raystack/components/slider/slider.module.css @@ -19,20 +19,22 @@ align-items: center; } +/* Track, range and small thumb: round from `medium` up, square at `none` and + `small`; see theme.css. The large thumb is a grip and keeps the pill rule. */ .track { position: relative; flex-grow: 1; height: var(--rs-space-2); background-color: var(--rs-color-background-neutral-secondary); margin: 0 var(--rs-space-4); - border-radius: var(--rs-radius-full); + border-radius: max(var(--rs-radius-1), var(--rs-radius-thumb, 9999px)); } .indicator { position: absolute; height: 100%; background-color: var(--rs-color-background-accent-emphasis); - border-radius: var(--rs-radius-full); + border-radius: max(var(--rs-radius-1), var(--rs-radius-thumb, 9999px)); } .thumb { @@ -82,7 +84,7 @@ .thumbSmall { width: var(--rs-space-3); height: var(--rs-space-5); - border-radius: var(--rs-radius-full); + border-radius: max(var(--rs-radius-1), var(--rs-radius-thumb, 9999px)); background-color: var(--rs-color-background-base-primary); border: 0.5px solid var(--rs-color-border-base-tertiary); box-shadow: var(--rs-shadow-soft); diff --git a/packages/raystack/components/switch/switch.module.css b/packages/raystack/components/switch/switch.module.css index 531ccfbac..83a8f01d1 100644 --- a/packages/raystack/components/switch/switch.module.css +++ b/packages/raystack/components/switch/switch.module.css @@ -8,7 +8,9 @@ position: relative; cursor: pointer; background: var(--rs-color-background-neutral-secondary); - border-radius: var(--rs-radius-full); + /* Round from `medium` up, square at `none` and `small`; see theme.css. */ + --switch-radius: max(var(--rs-radius-2), var(--rs-radius-thumb, 9999px)); + border-radius: var(--switch-radius); transition: background-color var(--rs-duration-normal) var(--rs-ease-out); } @@ -25,6 +27,7 @@ .switch.small { width: 26px; height: var(--rs-space-5); + --switch-radius: max(var(--rs-radius-1), var(--rs-radius-thumb, 9999px)); } .switch:not([data-disabled]):hover { @@ -54,7 +57,8 @@ width: var(--rs-space-5); height: var(--rs-space-5); background: var(--rs-color-foreground-base-emphasis); - border-radius: var(--rs-radius-full); + /* Concentric with the track: its radius less the thumb inset. */ + border-radius: max(0px, calc(var(--switch-radius) - var(--rs-space-1))); transform: translateX(var(--rs-space-1)); } diff --git a/packages/raystack/styles/theme.css b/packages/raystack/styles/theme.css index b13237718..3b5b711dd 100644 --- a/packages/raystack/styles/theme.css +++ b/packages/raystack/styles/theme.css @@ -48,10 +48,14 @@ --rs-scaling: 1.1; } -/* Radius. `--rs-radius-full` is the legacy pill token; `radius.css` overrides it. */ +/* Radius. Three pill tokens: `--rs-radius-pill` rounds controls only at `full`; + `--rs-radius-thumb` keeps round controls (Switch, Slider) round from `medium` + up and squares them at `none` and `small`, as Radix Themes does; + `--rs-radius-full` is the legacy pill token that `radius.css` overrides. */ :where([data-theme]) { --rs-radius-factor: 1; --rs-radius-pill: 0px; + --rs-radius-thumb: 9999px; --rs-radius-1: calc(2px * var(--rs-scaling) * var(--rs-radius-factor)); --rs-radius-2: calc(4px * var(--rs-scaling) * var(--rs-radius-factor)); @@ -65,22 +69,27 @@ :where([data-radius="none"]) { --rs-radius-factor: 0; --rs-radius-pill: 0px; + --rs-radius-thumb: 0px; } :where([data-radius="small"]) { --rs-radius-factor: 0.75; --rs-radius-pill: 0px; + --rs-radius-thumb: 0px; } :where([data-radius="medium"]) { --rs-radius-factor: 1; --rs-radius-pill: 0px; + --rs-radius-thumb: 9999px; } :where([data-radius="large"]) { --rs-radius-factor: 1.5; --rs-radius-pill: 0px; + --rs-radius-thumb: 9999px; } :where([data-radius="full"]) { --rs-radius-factor: 1.5; --rs-radius-pill: 9999px; + --rs-radius-thumb: 9999px; } /* Surfaces and panel background */ From 60c636fc46e7aa21591b475095f53c21c5091052 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 17 Sep 2026 23:14:55 +0530 Subject: [PATCH 05/22] docs(theme): tighten the ThemePreview page and add nesting and panel 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. --- .../src/content/docs/theme/preview/demo.ts | 230 +++++++++++++-- .../src/content/docs/theme/preview/index.mdx | 276 +++++++----------- 2 files changed, 318 insertions(+), 188 deletions(-) diff --git a/apps/www/src/content/docs/theme/preview/demo.ts b/apps/www/src/content/docs/theme/preview/demo.ts index 994b4ab8d..dfcd1d8f1 100644 --- a/apps/www/src/content/docs/theme/preview/demo.ts +++ b/apps/www/src/content/docs/theme/preview/demo.ts @@ -1,22 +1,28 @@ 'use client'; -export const scopeDemo = { +export const panelDemo = { + type: 'code', + code: `` +}; + +export const appearanceDemo = { type: 'code', code: ` - - - Light scope - - - - - - - Dark scope - - - + {["light", "dark"].map(appearance => ( + + + {appearance} + + + + + ))} ` }; @@ -82,6 +88,197 @@ export const scalingDemo = { ` }; +export const panelBackgroundDemo = { + type: 'code', + code: ` + + {["solid", "translucent"].map(panelBackground => ( + + {/* The same two tokens every overlay surface uses */} + + {panelBackground} + + Open popover} /> + + The popup uses the same surface. + + + + + ))} + ` +}; + +export const nestingDemo = { + type: 'code', + code: ` + + + + indigo, medium + + + + + {/* Sets accent and radius; inherits appearance */} + + + + mint, full + + + + + {/* Sets only the accent; inherits the full radius */} + + + orange, inherited full + + + + + + + + ` +}; + +export const layoutDemo = { + type: 'code', + code: ` + + + {/* A dark scope paints its own background */} + + + + + + + + + + Inbox + + + + + + + + ` +}; + +export const portalDemo = { + type: 'code', + code: ` + + + + Popover} /> + + Rendered in a portal, themed by the scope. + + + + + + + Tooltip} /> + Dark, like its trigger + + + ` +}; + +export const controlledDemo = { + type: 'code', + code: ` +function ControlledScope() { + const [dark, setDark] = React.useState(false); + + return ( + + + + Dark + + + + + Controlled by the switch + + + + + ); +}` +}; + export const componentRadiusDemo = { type: 'code', code: ` @@ -100,8 +297,3 @@ export const switcherDemo = { type: 'code', code: `` }; - -export const panelDemo = { - type: 'code', - code: `` -}; diff --git a/apps/www/src/content/docs/theme/preview/index.mdx b/apps/www/src/content/docs/theme/preview/index.mdx index a54c4535d..18a1e8e8b 100644 --- a/apps/www/src/content/docs/theme/preview/index.mdx +++ b/apps/www/src/content/docs/theme/preview/index.mdx @@ -1,31 +1,32 @@ --- title: ThemePreview -description: The rewritten theme — tokens on a real element, seven settings, and per-component radius. +description: Mounts the theme on an element. Seven settings, nested scopes, and portals that follow. --- import { accentDemo, + appearanceDemo, componentRadiusDemo, + controlledDemo, + layoutDemo, + nestingDemo, + panelBackgroundDemo, panelDemo, + portalDemo, radiusDemo, scalingDemo, - scopeDemo, switcherDemo } from "./demo.ts"; -`ThemePreview` is the next `Theme`, shipping alongside the existing one so applications can migrate at their own pace. It mounts every token-bearing attribute on a **real element** instead of ``, which is what makes the root theme, a nested scope and a portal all behave the same way. - -Three long-standing problems close with that change: + -- **The root can be server-rendered.** Attributes on `` came only from an effect or a blocking script, so the server emitted nothing. Every setting is now an ordinary prop that renders on the first byte. -- **More than one provider can exist per page.** Nothing competes for ``, so an embedded widget or a second independent root just works. -- **Scoped themes reach into portals.** Theme values cross a portal through React context and are re-emitted onto the portalled element, so a popover opened inside a dark scope is dark. +`ThemePreview` is the next `Theme`. It renders a real element that carries every token, so the root theme, a nested scope and a portalled popup all use the same component. It server-renders, more than one can exist per page, and a popup opened inside a scope keeps that scope's theme. - `ThemePreview` is additive. The existing `Theme`, `useTheme` and `ThemeSwitcher` are unchanged and keep working. Do not nest one inside the other — pick one per application. + `ThemePreview` is additive. `Theme`, `useTheme` and `ThemeSwitcher` are unchanged. Use one or the other per application; do not nest them. -## Installation +## Usage ```tsx import { ThemePreview } from "@raystack/apsara"; @@ -39,60 +40,104 @@ export default function App() { } ``` -Tokens live on the element `ThemePreview` renders, so everything that needs them must be inside it. Consumer CSS and hand-rolled portals mounted outside the provider will not resolve `--rs-*`; the portalling components re-emit the theme onto their own popups, so only your own portals need moving inside. - -## Theme panel - -Every setting, live, next to a sampler of components. The panel is a scope with -`isRoot={false}`, so it re-themes itself without touching the page. - - +Tokens live on the element `ThemePreview` renders, so anything that reads `--rs-*` must be inside it. Portalled components re-emit the theme onto their popups; only your own portals need to move inside. ## Settings -One settings object describes the theme. Every key is independently seedable, controllable and persistable, and every key becomes a data attribute on the theme element. +Every setting is a key of one object. Each key can be seeded, controlled or persisted on its own, and each becomes a data attribute on the theme element. | Setting | Values | Default | Attribute | |---|---|---|---| -| `appearance` | `light`, `dark`, `system` | `system` | `data-theme` (resolved) | +| `appearance` | `light`, `dark`, `system` | `system` | `data-theme` | | `accentColor` | `indigo`, `orange`, `mint` | `indigo` | `data-accent-color` | -| `grayColor` | `gray`, `mauve`, `slate`, `sage`, `auto` | `auto` | `data-gray-color` (resolved) | +| `grayColor` | `gray`, `mauve`, `slate`, `sage`, `auto` | `auto` | `data-gray-color` | | `radius` | `none`, `small`, `medium`, `large`, `full` | `medium` | `data-radius` | | `scaling` | `0.9`, `0.95`, `1`, `1.05`, `1.1` | `1` | `data-scaling` | | `panelBackground` | `solid`, `translucent` | `solid` | `data-panel-background` | | `reducedMotion` | `true`, `false`, `system` | `system` | `data-reduced-motion` | -`appearance: "system"` resolves against `prefers-color-scheme` and `grayColor: "auto"` pairs a complementary gray to the accent, both before the attribute is written — `data-theme` only ever holds `light` or `dark`. - -Font families are deliberately not a setting. They are customised through CSS variables instead; see [Fonts](#fonts). +`system` and `auto` are resolved before the attribute is written, so `data-theme` is always `light` or `dark`. Fonts are CSS variables, not a setting; see [Fonts](#fonts). ### Appearance - + -### Accent +### Accent color + +`grayColor: "auto"` pairs a gray to the accent. ### Radius -Radius is a factor applied to a fixed base scale, so `radius="small"` means the same thing in every configuration. Pill shapes follow two further rules, as in Radix Themes: controls such as `Button` and `Chip` become pills only at `full`, while round controls such as `Switch` and the `Slider` track stay round from `medium` up and square off at `none` and `small`. Surfaces such as `Dialog` and `Popover` never become pills, and `Radio` is always round. +A factor over a fixed base scale. Controls such as `Button` become pills only at `full`; round controls such as `Switch` stay round from `medium` up and square off at `none` and `small`. Surfaces never become pills. ### Scaling -Scaling is a **zoom**, not a density control: it multiplies spacing, radius, type and line height together. Border and divider widths, and font weights, do not scale. +A zoom: spacing, radius, type and line height scale together. Borders and font weights do not. -## Controlled and uncontrolled +### Panel background + +Overlay surfaces are opaque by default. `translucent` blurs what is behind dialogs, drawers, menus, popovers, selects, tooltips and toasts. -`defaultValue` seeds uncontrolled keys; a stored user choice may override it. `value` is authoritative: a controlled key always wins, is never persisted and is never written by the inline script. + -Control is **per key**, so a page can drive appearance from a cookie while leaving accent and radius adjustable: +### Reduced motion + +`system` follows `prefers-reduced-motion`. `"true"` collapses the duration tokens, which stops transitions and any animation timed by a token. ```tsx + +``` + +## Nesting + +A nested `ThemePreview` inherits every key it does not set. + + + +A scope with its own `light` or `dark` appearance paints its background. One that only changes accent, gray, radius or scaling is transparent. `hasBackground` overrides either. + + + +### Portals + +Theme values travel through React context, so every portalling component re-emits them onto its popup. A menu opened inside a dark scope is dark, with nothing to configure. + + + +### isRoot + +One theme per document owns the page colour scheme: the scrollbar, the overscroll area and native widget defaults. The first theme with no ancestor claims it. An embedded widget that has no ancestor theme but does not own the page must pass `isRoot={false}`. + +```tsx + + + +``` + +### render + +Merge the theme onto your own element instead of adding a wrapper: + +```tsx +}> + + +``` + +## Controlled + +`defaultValue` seeds a key; `value` controls it. A controlled key always wins, is never persisted and is never written by the inline script. Control is per key. + + + +```tsx +// Appearance from a cookie; accent stays adjustable and persisted -// Persist only the appearance; the rest stays in memory +// Only the appearance ``` -A namespace is one `localStorage` entry holding one JSON object alongside a schema version. A write **merges**: it applies only the settings its `persist` covers and leaves every other field intact, including fields owned by a theme with a different `persist` on the same namespace. A missing or unparseable entry falls back to the seeded defaults and is overwritten on the next write; a field outside its union is discarded individually. - -Sharing a `persistKey` is a supported feature, not a hazard — it is exactly what a documentation page wants when several live examples should switch appearance together. Themes on one namespace stay in step within a document, and across tabs through the `storage` event. +Themes sharing a `persistKey` stay in step, across tabs as well. Writes merge into the stored object, so themes with different `persist` lists can share one key. ## Server rendering -Every setting is an ordinary prop, so React server-renders the seeded values on the first byte. Two things the server cannot know: what the browser has **stored**, and which way the OS leans when appearance is `system`. The inline script closes both gaps before first paint. It reads the namespace and patches every persisted, uncontrolled setting it finds there, and where nothing is stored it resolves a `system` appearance against `prefers-color-scheme`. A first visit on a dark OS therefore paints dark instead of flashing light. +Settings are props, so the server renders them. An inline script, the theme element's first child, patches in what the server cannot know before first paint: stored values, and the OS answer for a `system` appearance. A pinned appearance with no `persistKey` ships no script. Pass `nonce` if your CSP needs one. ```tsx // Next.js App Router: app/layout.tsx @@ -137,48 +180,9 @@ export default function RootLayout({ children }) { } ``` -No `suppressHydrationWarning` on `` is needed: nothing is written there. The theme element carries it itself, which suppresses attribute diffs exactly one level deep. - -The script renders as the theme element's first child and patches its own parent. It is emitted when the theme has a `persistKey` with uncontrolled settings to read, or when its appearance is `system`. A theme with a pinned light or dark appearance and no persistence, such as one reading appearance from a cookie, ships no script at all. Pass `nonce` if your CSP requires one. - -## Scoping - -A nested `ThemePreview` inherits every key it does not set, so "inherit" is expressed by omission rather than by a value. - -```tsx - - {/* Only the accent changes; appearance, radius and scaling inherit */} - - - - -``` - -### hasBackground - -The component cannot infer whether it should paint, because re-tinting the accent and flipping a panel to dark use the same component but want opposite behaviour. `hasBackground` decides, and its default follows a heuristic: true at the root, true for a nested theme with a `light` or `dark` appearance of its own, whether seeded, controlled, stored or set at runtime, and false for one that only changes accent, gray, radius or scaling. - -Pass `hasBackground={false}` if your application paints its own page background. Foreground colour applies regardless. - -### isRoot - -Exactly one theme per document may own the page's colour scheme, which is what the browser paints in the overscroll area, the document scrollbar, the region below short content, and native widget defaults. That theme carries a `data-rs-root` marker, and `` derives `color-scheme` from it with `:has()` — no JavaScript, nothing written to ``. - -A theme claims the marker when it finds no ancestor theme. A theme that has no ancestor but does **not** own the page — an embedded widget, a micro-frontend — must pass `isRoot={false}`. Everything else about it is unchanged: it still carries `data-theme`, so its own subtree still gets a `color-scheme`; only the three document-level surfaces defer to the host. - -If two elements carry the marker with conflicting appearances, neither wins by position — both rules match `:root` at equal specificity, so `dark` wins because it is declared later. Set `isRoot={false}` on the one that does not own the page. - -### render - -`render` is the `asChild`-style escape hatch: it merges the theme's attributes onto an element you supply instead of adding a wrapper node. - -```tsx -}> - - -``` +Nothing is written to ``, so it needs no `suppressHydrationWarning`. -## The useThemePreview hook +## useThemePreview ```tsx import { useThemePreview } from "@raystack/apsara"; @@ -197,26 +201,18 @@ function AppearanceToggle() { -`value` is the settings as set, `system` and `auto` included; `resolved` is the settings as applied. Branch on `resolved`, not `value` — that is what the viewer is actually looking at. - -`root` is the same shape bound to the root provider, so a control inside a scope can flip the page theme: +`value` is what was set, `system` and `auto` included; `resolved` is what is on screen. `root` is the same handle bound to the root provider, so a control inside a scope can change the page: ```tsx -function PageToggle() { - const { root } = useThemePreview(); - return ( - - ); -} +const { root } = useThemePreview(); +root.setValue({ appearance: "dark" }); ``` -The hook **throws** outside a provider rather than returning a silent no-op: every colour token is declared under `[data-theme]`, so a tree with no provider has no colours at all. +The hook throws outside a provider. ### ThemePreviewSwitcher -A ready-made icon button that flips between light and dark. It reads `resolved.appearance`, so `system` shows the icon for what is actually on screen. +An icon button that flips between light and dark. It follows `resolved.appearance`. @@ -224,65 +220,38 @@ A ready-made icon button that flips between light and dark. It reads `resolved.a ## Per-component radius -Components take a `radius` prop with the same five values as the theme setting. Two rules distinguish it from a subtree scope: - -1. It affects **only** the component it is set on, never anything inside it. Tree-level changes belong to `ThemePreview`. -2. It does **not compound** with the theme radius — a `large` theme with a `small` component yields small, not large multiplied by small. +Components accept a `radius` prop with the theme's five values. It affects only that component and does not compound with the theme radius. -`radius` is available on `Button`, `IconButton`, `Badge`, `Callout`, `Chip`, `Input`, `TextArea`, `Image`, `Avatar`, and on the portalled sub-components: `Dialog.Content`, `AlertDialog.Content`, `Drawer.Content`, `Popover.Content`, `Menu.Content`, `ContextMenu.Content`, `Select.Content`, `Combobox.Content`, `Tooltip.Content`, `PreviewCard.Content`, `Command.DialogContent` and `Tour.Content`. - -It lives on the portalled sub-component rather than the root because a portal carries nothing forward: ``, not ``. +Available on `Button`, `IconButton`, `Badge`, `Callout`, `Chip`, `Input`, `TextArea`, `Image`, `Avatar`, and on the portalled parts `Dialog.Content`, `AlertDialog.Content`, `Drawer.Content`, `Popover.Content`, `Menu.Content`, `ContextMenu.Content`, `Select.Content`, `Combobox.Content`, `Tooltip.Content`, `PreviewCard.Content`, `Command.DialogContent` and `Tour.Content`. It goes on the portalled part, not the root: ``. -## Portals - -Theme values travel through React context, not the DOM, so a portalled popup would otherwise land in `document.body` where no `--rs-*` token is defined. Every portalling component re-emits the inherited theme onto its portalled element automatically: the popup carries the same `rs-theme` class and data attributes as its nearest `ThemePreview`, so a popover opened inside a dark scope is dark. Nothing is required from you. - -## Panel background - -`panelBackground` selects between opaque and translucent overlay surfaces: dialogs, drawers, menus, popovers, selects, comboboxes, tooltips, toasts, preview cards, command palettes and tour cards. The default is `solid`, so translucency is opt-in. - -```tsx - -``` +## Customization -## Reduced motion +### Tokens -`reducedMotion: "system"` is the default and honours `prefers-reduced-motion`, which fifty component stylesheets already respect. A forced `"true"` collapses the duration tokens to a near-zero value, which neutralises transitions and any animation whose duration comes from a token. - -It does not reach animations gated behind `@media (prefers-reduced-motion: no-preference)` blocks. Converting those is tracked separately. - -## Overriding tokens - -Every `--rs-*` declaration in the package is wrapped in `:where()`, so it contributes zero specificity. Every theme element — root, scope or portal re-injection — carries a stable, unhashed `rs-theme` class. A single-class rule of yours therefore beats every built-in token declaration, without `!important` and regardless of stylesheet order: +Every `--rs-*` declaration is wrapped in `:where()` and every theme element carries the `rs-theme` class, so one class selector overrides any token without `!important`: ```css .rs-theme { --rs-color-background-accent-emphasis: #6d28d9; --rs-radius-3: 10px; } -``` -Scope it like any CSS: - -```css .marketing-page .rs-theme { --rs-font-title: "Playfair Display", serif; } ``` -Inline `style` works too, since tokens now live on a real element: +Inline `style` works too: ```tsx ``` -This cuts both ways: an unintended selector can overwrite tokens as easily as an intended one. Given the alternative is overrides that cannot be made to work at all, it is the better failure. - -## Fonts +### Fonts -Three CSS variables and no prop: +Three CSS variables, no prop: | Token | Role | |---|---| @@ -297,22 +266,11 @@ Three CSS variables and no prop: } ``` -There is no `fontFamily` prop. A font is a one-time branding choice with no runtime picker, and being free-form it could never be a data attribute like the seven settings. A prop would have to write inline custom properties, which beat every `:where()`-wrapped token rule — making fonts the one part of the token system you could not override from a stylesheet. - -Two stylesheets are published and you import exactly one: - -| Export | Contents | -|---|---| -| `@raystack/apsara/style.css` | Tokens, components, and the font imports | -| `@raystack/apsara/style-no-fonts.css` | Tokens and components, no font imports | - - - Custom fonts carry a caveat, not a guarantee. The typography scale pairs pixel font sizes with pixel line heights, and its letter spacing is tuned for Inter. A font with different metrics leaves line heights uncentred and tracking wrong, and because controls are sized by padding plus line-height, their dimensions shift with it. - +Import one stylesheet: `@raystack/apsara/style.css` includes the font imports, `@raystack/apsara/style-no-fonts.css` leaves them out for self-hosted fonts. The type scale is tuned for Inter, so another font may need its line heights and tracking adjusted. ## Migrating from Theme -`ThemePreview` is a clean break rather than a superset. Migrate a whole application at once; do not nest the two. +Migrate a whole application at once; do not nest the two. | Removed | Replacement | |---|---| @@ -329,8 +287,8 @@ Two stylesheets are published and you import exactly one: | `useTheme().theme` / `.setTheme` / `.resolvedTheme` / `.systemTheme` | `value` / `setValue` / `resolved` / `systemAppearance` | | `useTheme().themes` / `.forcedTheme` / `.style` / `.scopes` | None | | `useTheme({ storageKey })` | `useThemePreview().root` | -| `storageKey` | `persistKey`, which now also gates persistence rather than only naming it | -| Persistence at the root by default | `persistKey` is required to persist, at the root as well as in a scope | +| `storageKey` | `persistKey`, which also gates persistence | +| Persistence at the root by default | `persistKey` is required to persist | | `ThemeSwitcher` | `ThemePreviewSwitcher` | ### Before and after @@ -365,7 +323,7 @@ Two stylesheets are published and you import exactly one: ``` ```tsx -// Before — force dark for a subtree +// Before: force dark for a subtree @@ -377,7 +335,7 @@ Two stylesheets are published and you import exactly one: ``` ```tsx -// Before — flip the page theme from inside a scope +// Before: flip the page theme from inside a scope const { setTheme } = useTheme({ storageKey: "theme" }); // After @@ -385,33 +343,13 @@ const { root } = useThemePreview(); root.setValue({ appearance: "dark" }); ``` -### style is retired - -`style="modern" | "traditional"` decomposed exactly into a radius level plus a font pair, both of which are now first-class. Traditional was not a constant multiple of modern — the two scales ran 2/4/6/8/12/16 and 8/16/20/24/32/40 — so it could not survive as a factor without changing its output. It becomes a recipe instead: - -```tsx - -``` - -```css -.rs-theme { - --rs-font-title: "Lora", serif; - --rs-font-body: "Josefin Sans", sans-serif; -} -``` - -### Component radius values - -`Image` and `Avatar` had bespoke radius scales disconnected from the theme; both now use the shared five values. - -- **`Image`** — `none`, `medium` and `full` are unchanged; `small` is now 0.75× the base step rather than a separate token, and `large` is new. -- **`Avatar`** — no default `radius` any more, so it follows the theme radius like every other component, with a base step that still grows with `size`. An explicit `radius` overrides as before; `full` is unchanged. - -### Other things to know +### Also -- Tokens are no longer on ``, so consumer CSS and hand-rolled portals living outside the provider stop resolving `--rs-*`. Move them inside the provider. +- `style="modern" | "traditional"` becomes a radius level plus a font pair: `defaultValue={{ radius: "large" }}` with `--rs-font-title` and `--rs-font-body` set in CSS. +- `Image` and `Avatar` use the shared five radius values. `Image` gains `large`. `Avatar` has no default radius and follows the theme; pass `radius="full"` to keep circles. +- Tokens are no longer on ``. Consumer CSS and hand-rolled portals outside the provider must move inside it. - `useThemePreview` throws outside a provider instead of returning a no-op. -- `persistKey` reads only the JSON entry `ThemePreview` writes. A bare theme name that `Theme` stored under the same key is ignored, so a migrated user starts from the seed once. Use a fresh `persistKey` if that matters. +- `persistKey` ignores the bare theme name `Theme` stored, so a migrated user starts from the seed once. Use a new key if that matters. ## API Reference From 6313b57518b30bf4d71d8c70919d03220ad387a3 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Fri, 18 Sep 2026 10:01:43 +0530 Subject: [PATCH 06/22] fix(theme): scaling, radius and panel fixes from the live test pass 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. --- apps/www/src/components/theme-panel-demo.tsx | 10 ++++- .../src/content/docs/theme/preview/demo.ts | 12 ++++- .../components/accordion/accordion.module.css | 2 +- .../components/badge/badge.module.css | 12 ++--- .../components/calendar/calendar.module.css | 4 +- .../chat-panel/chat-panel.module.css | 2 +- .../raystack/components/chat/chat.module.css | 2 +- .../components/data-view/components/list.tsx | 19 +++++--- .../data-view/components/timeline.tsx | 27 +++++++++--- .../components/data-view/hooks/useScaling.tsx | 44 +++++++++++++++++++ .../filter-chip/filter-chip.module.css | 13 ++++-- .../components/indicator/indicator.module.css | 11 ++--- .../components/input/input.module.css | 8 ++++ .../components/meter/meter.module.css | 5 ++- .../preview-card/preview-card.module.css | 2 +- .../components/progress/progress.module.css | 5 ++- .../prompt-input/prompt-input.module.css | 2 +- .../components/sidebar/sidebar.module.css | 6 +++ .../components/switch/switch.module.css | 6 ++- .../raystack/components/tabs/tabs.module.css | 5 +++ .../components/tooltip/tooltip.module.css | 2 +- .../raystack/components/tour/tour.module.css | 2 +- 22 files changed, 157 insertions(+), 44 deletions(-) create mode 100644 packages/raystack/components/data-view/hooks/useScaling.tsx diff --git a/apps/www/src/components/theme-panel-demo.tsx b/apps/www/src/components/theme-panel-demo.tsx index e9d52216a..daf05697e 100644 --- a/apps/www/src/components/theme-panel-demo.tsx +++ b/apps/www/src/components/theme-panel-demo.tsx @@ -131,15 +131,21 @@ export default function ThemePanelDemo() { - + - + {/* The separator's own `height: 100%` resolves to 0 against a + content-sized row, so let the stretch decide its height. */} + diff --git a/apps/www/src/content/docs/theme/preview/demo.ts b/apps/www/src/content/docs/theme/preview/demo.ts index dfcd1d8f1..72f5ea7b2 100644 --- a/apps/www/src/content/docs/theme/preview/demo.ts +++ b/apps/www/src/content/docs/theme/preview/demo.ts @@ -295,5 +295,15 @@ export const componentRadiusDemo = { export const switcherDemo = { type: 'code', - code: `` + code: ` + + + + Flips this scope + + ` }; diff --git a/packages/raystack/components/accordion/accordion.module.css b/packages/raystack/components/accordion/accordion.module.css index 963d31bae..a10dbf79c 100644 --- a/packages/raystack/components/accordion/accordion.module.css +++ b/packages/raystack/components/accordion/accordion.module.css @@ -10,7 +10,7 @@ .accordion-trigger { display: flex; /* Todo: var does not exist for 36px */ - min-height: 36px; + min-height: calc(36px * var(--rs-scaling, 1)); padding: 0 var(--rs-space-4); justify-content: space-between; align-items: center; diff --git a/packages/raystack/components/badge/badge.module.css b/packages/raystack/components/badge/badge.module.css index eae7c6415..57cfbb358 100644 --- a/packages/raystack/components/badge/badge.module.css +++ b/packages/raystack/components/badge/badge.module.css @@ -17,7 +17,7 @@ line-height: var(--rs-line-height-micro); letter-spacing: var(--rs-letter-spacing-micro); /* Todo: var does not exist for 18px */ - height: 18px; + height: calc(18px * var(--rs-scaling, 1)); } .badge-small { @@ -25,7 +25,7 @@ line-height: var(--rs-line-height-small); letter-spacing: var(--rs-letter-spacing-small); /* Todo: var does not exist for 22px */ - height: 22px; + height: calc(22px * var(--rs-scaling, 1)); } .badge-regular { @@ -80,8 +80,8 @@ .badge-micro .icon { /* Todo: var does not exist for 10px */ - width: 10px; - height: 10px; + width: calc(10px * var(--rs-scaling, 1)); + height: calc(10px * var(--rs-scaling, 1)); } .badge-small .icon { @@ -91,8 +91,8 @@ .badge-regular .icon { /* Todo: var does not exist for 14px */ - width: 14px; - height: 14px; + width: calc(14px * var(--rs-scaling, 1)); + height: calc(14px * var(--rs-scaling, 1)); } .sr-only { diff --git a/packages/raystack/components/calendar/calendar.module.css b/packages/raystack/components/calendar/calendar.module.css index ddcb9550c..b256aca69 100644 --- a/packages/raystack/components/calendar/calendar.module.css +++ b/packages/raystack/components/calendar/calendar.module.css @@ -4,7 +4,7 @@ background: var(--rs-color-background-base-primary); width: fit-content; /* Todo: var does not exist for 346px */ - min-height: 346px; + min-height: calc(346px * var(--rs-scaling, 1)); } .captionLabel, @@ -208,7 +208,7 @@ } .dropdownContent { - max-height: 260px; + max-height: calc(260px * var(--rs-scaling, 1)); } .disabled { diff --git a/packages/raystack/components/chat-panel/chat-panel.module.css b/packages/raystack/components/chat-panel/chat-panel.module.css index 363554d88..d07065313 100644 --- a/packages/raystack/components/chat-panel/chat-panel.module.css +++ b/packages/raystack/components/chat-panel/chat-panel.module.css @@ -220,7 +220,7 @@ height: var(--rs-space-11); padding: 0; border: 0.5px solid var(--rs-color-border-base-primary); - border-radius: var(--rs-radius-full); + border-radius: max(var(--rs-radius-2), var(--rs-radius-thumb, 9999px)); background: var(--rs-color-background-base-primary); color: var(--rs-color-foreground-base-primary); box-shadow: var(--rs-shadow-floating); diff --git a/packages/raystack/components/chat/chat.module.css b/packages/raystack/components/chat/chat.module.css index ba65841bf..195dcce99 100644 --- a/packages/raystack/components/chat/chat.module.css +++ b/packages/raystack/components/chat/chat.module.css @@ -65,7 +65,7 @@ gap: var(--rs-space-2); padding: var(--rs-space-2) var(--rs-space-4); border: 0.5px solid var(--rs-color-border-base-primary); - border-radius: var(--rs-radius-full); + border-radius: max(var(--rs-radius-2), var(--rs-radius-thumb, 9999px)); background: var(--rs-color-background-base-primary); color: var(--rs-color-foreground-base-primary); font-size: var(--rs-font-size-mini); diff --git a/packages/raystack/components/data-view/components/list.tsx b/packages/raystack/components/data-view/components/list.tsx index 0a9c77169..bbf9738dc 100644 --- a/packages/raystack/components/data-view/components/list.tsx +++ b/packages/raystack/components/data-view/components/list.tsx @@ -24,6 +24,7 @@ import { import { useDataView } from '../hooks/useDataView'; import { useElementHeight } from '../hooks/useElementHeight'; import { useInfiniteScroll } from '../hooks/useInfiniteScroll'; +import { useScaling } from '../hooks/useScaling'; import { useStickyGroupAnchor } from '../hooks/useStickyGroupAnchor'; import { useVirtualRows } from '../hooks/useVirtualRows'; import { FilterSummary } from './clear-filters'; @@ -80,9 +81,6 @@ export function DataViewList({ const headersVisible = showHeaders ?? isTableVariant; const ariaRole = role ?? (isTableVariant ? 'table' : 'list'); const dividers = showDividers ?? isTableVariant; - const effectiveRowHeight = - estimatedRowHeight ?? - (isTableVariant ? DEFAULT_TABLE_ROW_HEIGHT : DEFAULT_LIST_ROW_HEIGHT); const visibleLeafColumns = table.getVisibleLeafColumns(); @@ -130,6 +128,15 @@ export function DataViewList({ // Measure the column-header row so sticky group elements sit directly under it. const [headerMeasureRef, headerHeight] = useElementHeight(); + // Rows are sized by tokens, so the estimates the virtualizer and the sticky + // group anchor run on follow the theme zoom. An explicit prop is left alone. + const scaling = useScaling(scrollRef); + const groupHeaderHeight = GROUP_HEADER_HEIGHT * scaling; + const effectiveRowHeight = + estimatedRowHeight ?? + (isTableVariant ? DEFAULT_TABLE_ROW_HEIGHT : DEFAULT_LIST_ROW_HEIGHT) * + scaling; + // Group offsets, needed for the sticky group anchor under virtualization. const group_by = tableQuery?.group_by?.[0]; const isGrouped = Boolean(group_by) && group_by !== defaultGroupOption.id; @@ -150,10 +157,10 @@ export function DataViewList({ data: row.original as GroupedData }); } - offset += isGroupHeader ? GROUP_HEADER_HEIGHT : effectiveRowHeight; + offset += isGroupHeader ? groupHeaderHeight : effectiveRowHeight; }); return list; - }, [rows, effectiveRowHeight]); + }, [rows, effectiveRowHeight, groupHeaderHeight]); const { totalSize, items, measureRef } = useVirtualRows({ enabled: virtualized, @@ -162,7 +169,7 @@ export function DataViewList({ estimatedRowHeight: effectiveRowHeight, estimateSize: row => { const isGroupHeader = row?.subRows && row.subRows.length > 0; - return isGroupHeader ? GROUP_HEADER_HEIGHT : effectiveRowHeight; + return isGroupHeader ? groupHeaderHeight : effectiveRowHeight; } }); diff --git a/packages/raystack/components/data-view/components/timeline.tsx b/packages/raystack/components/data-view/components/timeline.tsx index b6e463a4c..efd122c01 100644 --- a/packages/raystack/components/data-view/components/timeline.tsx +++ b/packages/raystack/components/data-view/components/timeline.tsx @@ -26,6 +26,7 @@ import { TimelineScale } from '../data-view.types'; import { useDataView } from '../hooks/useDataView'; +import { useScaling } from '../hooks/useScaling'; import { orderByX } from '../utils/order-by-x'; import { packLanes, packLanesBySortValue } from '../utils/pack-lanes'; import { @@ -431,10 +432,10 @@ export function DataViewTimeline({ onVisibleRangeChange, actionsRef, lanePacking = 'auto', - estimatedRowHeight = DEFAULT_ROW_HEIGHT, - laneGap = DEFAULT_LANE_GAP, - minCardWidth = DEFAULT_MIN_CARD_WIDTH, - estimatedPointWidth = DEFAULT_POINT_WIDTH, + estimatedRowHeight: estimatedRowHeightProp, + laneGap: laneGapProp, + minCardWidth: minCardWidthProp, + estimatedPointWidth: estimatedPointWidthProp, virtualized = false, showGroupHeaders = true, classNames = {} @@ -468,6 +469,17 @@ export function DataViewTimeline({ const scrollRef = useRef(null); + // The defaults describe token-driven boxes, so they follow the theme zoom. + // An explicit prop is a pixel value the consumer chose, and is left alone. + const scaling = useScaling(scrollRef); + const estimatedRowHeight = + estimatedRowHeightProp ?? DEFAULT_ROW_HEIGHT * scaling; + const laneGap = laneGapProp ?? DEFAULT_LANE_GAP * scaling; + const minCardWidth = minCardWidthProp ?? DEFAULT_MIN_CARD_WIDTH * scaling; + const estimatedPointWidth = + estimatedPointWidthProp ?? DEFAULT_POINT_WIDTH * scaling; + const groupBandHeight = GROUP_BAND_HEIGHT * scaling; + // Viewport fill. When the domain renders narrower than the scroll // container, the domain end extends so the axis/gridlines span the full // visible width (see `minWidth` in createTimeScale). Extension only ever @@ -907,7 +919,7 @@ export function DataViewTimeline({ for (const section of laidOutSections) { const sectionTop = y; const banded = showGroupHeaders && section.group !== null; - if (banded) y += GROUP_BAND_HEIGHT; + if (banded) y += groupBandHeight; y += laneGap; for (let i = 0; i < section.laneCount; i++) { const lane = section.laneOffset + i; @@ -939,7 +951,7 @@ export function DataViewTimeline({ const uniform = fixedLaneHeight && laneCount > 0 && laidOutSections.length === 1 ? { - first: bands.length > 0 ? GROUP_BAND_HEIGHT + laneGap : laneGap, + first: bands.length > 0 ? groupBandHeight + laneGap : laneGap, pitch: estimatedRowHeight + laneGap, height: estimatedRowHeight } @@ -962,6 +974,7 @@ export function DataViewTimeline({ estimatedRowHeight, fixedLaneHeight, laneGap, + groupBandHeight, showGroupHeaders, measureVersion ]); @@ -1823,7 +1836,7 @@ export function DataViewTimeline({ styles.timelineGroupHeader, classNames.groupHeader )} - style={{ height: GROUP_BAND_HEIGHT }} + style={{ height: groupBandHeight }} data-slot='data-view-timeline-group-header' > {/* Sticky-left so the label stays readable while panning. */} diff --git a/packages/raystack/components/data-view/hooks/useScaling.tsx b/packages/raystack/components/data-view/hooks/useScaling.tsx new file mode 100644 index 000000000..eb7f370a1 --- /dev/null +++ b/packages/raystack/components/data-view/hooks/useScaling.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { type RefObject, useCallback, useEffect, useState } from 'react'; + +import { useIsomorphicLayoutEffect } from '~/hooks'; + +/** + * The resolved `--rs-scaling` for a subtree. Both renderers keep geometry in + * JS — virtualizer estimates, group-header offsets, timeline lane math — which + * the token-driven CSS around them zooms without. Reading the factor here lets + * that geometry follow the theme too. + * + * 1 on the server and for the first render; the element has to exist before + * the custom property can be resolved. + */ +export function useScaling(ref: RefObject): number { + const [scaling, setScaling] = useState(1); + + const read = useCallback(() => { + const element = ref.current; + if (!element) return; + const next = Number.parseFloat( + getComputedStyle(element).getPropertyValue('--rs-scaling') + ); + if (!Number.isFinite(next) || next <= 0) return; + setScaling(previous => (previous === next ? previous : next)); + }, [ref]); + + useIsomorphicLayoutEffect(read, [read]); + + // A theme anywhere above may change its scaling without re-rendering here. + useEffect(() => { + if (typeof MutationObserver === 'undefined') return; + const observer = new MutationObserver(read); + observer.observe(document.documentElement, { + subtree: true, + attributes: true, + attributeFilter: ['data-scaling'] + }); + return () => observer.disconnect(); + }, [read]); + + return scaling; +} diff --git a/packages/raystack/components/filter-chip/filter-chip.module.css b/packages/raystack/components/filter-chip/filter-chip.module.css index a785595d8..ef210df74 100644 --- a/packages/raystack/components/filter-chip/filter-chip.module.css +++ b/packages/raystack/components/filter-chip/filter-chip.module.css @@ -164,6 +164,11 @@ button.selectValue:hover { max-height: var(--rs-space-4); } +.leadingIcon > svg { + width: var(--rs-space-4); + height: var(--rs-space-4); +} + .inputFieldWrapper { padding: 0; min-width: 0; @@ -172,8 +177,8 @@ button.selectValue:hover { .inputField { border-radius: 0; - min-height: 22px; - height: 22px; + min-height: calc(22px * var(--rs-scaling, 1)); + height: calc(22px * var(--rs-scaling, 1)); border: none; box-shadow: none; } @@ -201,8 +206,8 @@ button.selectValue:hover { justify-content: space-between; align-items: center; width: fit-content; - height: 22px; - min-height: 22px; + height: calc(22px * var(--rs-scaling, 1)); + min-height: calc(22px * var(--rs-scaling, 1)); border: none; box-shadow: none; } diff --git a/packages/raystack/components/indicator/indicator.module.css b/packages/raystack/components/indicator/indicator.module.css index 3d922c5e5..19409c6e7 100644 --- a/packages/raystack/components/indicator/indicator.module.css +++ b/packages/raystack/components/indicator/indicator.module.css @@ -12,7 +12,7 @@ align-items: center; justify-content: center; font-variant-numeric: tabular-nums; - border-radius: var(--rs-radius-full); + border-radius: max(var(--rs-radius-1), var(--rs-radius-thumb, 9999px)); white-space: nowrap; font-weight: var(--rs-font-weight-regular); font-size: var(--rs-font-size-micro); @@ -27,15 +27,16 @@ .indicator:has(.label) { /* Todo: var does not exist for 18px */ - min-width: 18px; - height: 18px; + min-width: calc(18px * var(--rs-scaling, 1)); + height: calc(18px * var(--rs-scaling, 1)); } +/* Always round: a status dot, not a control. */ .dot { display: block; /* Todo: var does not exist for 5px */ - width: 5px; - height: 5px; + width: calc(5px * var(--rs-scaling, 1)); + height: calc(5px * var(--rs-scaling, 1)); border-radius: var(--rs-radius-full); } diff --git a/packages/raystack/components/input/input.module.css b/packages/raystack/components/input/input.module.css index 964079028..36424cf37 100644 --- a/packages/raystack/components/input/input.module.css +++ b/packages/raystack/components/input/input.module.css @@ -55,6 +55,14 @@ margin-right: var(--rs-space-3); } +/* The slot sizes the icon, so an icon drawn at a fixed 16px zooms with the + theme instead of overflowing the slot below scaling 1. */ +.leading-icon > svg, +.trailing-icon > svg { + width: var(--rs-space-5); + height: var(--rs-space-5); +} + .input-field { width: 100%; padding: 0 var(--rs-space-3); diff --git a/packages/raystack/components/meter/meter.module.css b/packages/raystack/components/meter/meter.module.css index c95ddb267..4ac6c9ffd 100644 --- a/packages/raystack/components/meter/meter.module.css +++ b/packages/raystack/components/meter/meter.module.css @@ -30,7 +30,10 @@ width: 100%; height: var(--rs-space-2); overflow: clip; - border-radius: 1px; + border-radius: max( + var(--rs-radius-pill, 0px), + calc(1px * var(--rs-scaling, 1) * var(--rs-radius-factor, 1)) + ); background-color: var(--rs-color-background-neutral-secondary); } diff --git a/packages/raystack/components/preview-card/preview-card.module.css b/packages/raystack/components/preview-card/preview-card.module.css index d266b3e5a..25188f9dd 100644 --- a/packages/raystack/components/preview-card/preview-card.module.css +++ b/packages/raystack/components/preview-card/preview-card.module.css @@ -38,7 +38,7 @@ } .arrow svg { - color: var(--rs-color-background-base-primary); + color: var(--rs-color-panel); } .arrow { diff --git a/packages/raystack/components/progress/progress.module.css b/packages/raystack/components/progress/progress.module.css index 716720135..986c36601 100644 --- a/packages/raystack/components/progress/progress.module.css +++ b/packages/raystack/components/progress/progress.module.css @@ -30,7 +30,10 @@ width: 100%; height: var(--rs-space-2); overflow: clip; - border-radius: 1px; + border-radius: max( + var(--rs-radius-pill, 0px), + calc(1px * var(--rs-scaling, 1) * var(--rs-radius-factor, 1)) + ); background-color: var(--rs-color-background-neutral-secondary); } diff --git a/packages/raystack/components/prompt-input/prompt-input.module.css b/packages/raystack/components/prompt-input/prompt-input.module.css index a6a17918f..c325260c4 100644 --- a/packages/raystack/components/prompt-input/prompt-input.module.css +++ b/packages/raystack/components/prompt-input/prompt-input.module.css @@ -113,7 +113,7 @@ height: var(--rs-space-7); padding: 0; border: none; - border-radius: var(--rs-radius-full); + border-radius: max(var(--rs-radius-2), var(--rs-radius-thumb, 9999px)); background: var(--rs-color-background-accent-emphasis); color: var(--rs-accent-contrast); cursor: pointer; diff --git a/packages/raystack/components/sidebar/sidebar.module.css b/packages/raystack/components/sidebar/sidebar.module.css index 6fcf77810..d10188979 100644 --- a/packages/raystack/components/sidebar/sidebar.module.css +++ b/packages/raystack/components/sidebar/sidebar.module.css @@ -227,6 +227,12 @@ flex-shrink: 0; } +/* Direct child only: the fallback path holds an Avatar, which sizes itself. */ +.nav-leading-icon > svg { + width: var(--rs-space-5); + height: var(--rs-space-5); +} + .nav-fallback-avatar { cursor: pointer; } diff --git a/packages/raystack/components/switch/switch.module.css b/packages/raystack/components/switch/switch.module.css index 83a8f01d1..ddbc03ba5 100644 --- a/packages/raystack/components/switch/switch.module.css +++ b/packages/raystack/components/switch/switch.module.css @@ -19,13 +19,15 @@ outline-offset: var(--rs-focus-ring-offset-accent); } +/* Thumb travel + thumb + inset, from the thumb's own tokens, so the thumb + stays inside the track at every scaling. */ .switch.large { - width: 34px; + width: calc(var(--rs-space-5) * 2 + var(--rs-space-1)); height: var(--rs-space-6); } .switch.small { - width: 26px; + width: calc(var(--rs-space-4) * 2 + var(--rs-space-1)); height: var(--rs-space-5); --switch-radius: max(var(--rs-radius-1), var(--rs-radius-thumb, 9999px)); } diff --git a/packages/raystack/components/tabs/tabs.module.css b/packages/raystack/components/tabs/tabs.module.css index 38f55489f..6460dfbd4 100644 --- a/packages/raystack/components/tabs/tabs.module.css +++ b/packages/raystack/components/tabs/tabs.module.css @@ -107,6 +107,11 @@ flex-shrink: 0; } +.trigger-icon > svg { + width: var(--rs-space-5); + height: var(--rs-space-5); +} + .indicator { position: absolute; background-color: var(--rs-color-background-base-primary); diff --git a/packages/raystack/components/tooltip/tooltip.module.css b/packages/raystack/components/tooltip/tooltip.module.css index bac8bc86a..250402f95 100644 --- a/packages/raystack/components/tooltip/tooltip.module.css +++ b/packages/raystack/components/tooltip/tooltip.module.css @@ -24,7 +24,7 @@ } .arrow svg { - color: var(--rs-color-background-base-primary); + color: var(--rs-color-panel); } .arrow { z-index: var(--rs-z-index-portal); diff --git a/packages/raystack/components/tour/tour.module.css b/packages/raystack/components/tour/tour.module.css index fc58c478d..fbb09de7b 100644 --- a/packages/raystack/components/tour/tour.module.css +++ b/packages/raystack/components/tour/tour.module.css @@ -151,7 +151,7 @@ .arrow svg { display: block; - color: var(--rs-color-background-base-primary); + color: var(--rs-color-panel); } .arrow[data-side="top"] { From 8035bfe40168e14e9fc2e6b22b4f7de6489b5b37 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Fri, 18 Sep 2026 10:30:04 +0530 Subject: [PATCH 07/22] docs(theme): make the panel background demos show translucency 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. --- apps/www/src/components/theme-panel-demo.tsx | 50 ++++++++++++------- .../src/content/docs/theme/preview/demo.ts | 4 +- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/apps/www/src/components/theme-panel-demo.tsx b/apps/www/src/components/theme-panel-demo.tsx index daf05697e..25790ba36 100644 --- a/apps/www/src/components/theme-panel-demo.tsx +++ b/apps/www/src/components/theme-panel-demo.tsx @@ -101,25 +101,39 @@ function Sampler() { Callouts follow the accent and the radius factor. - - - Tooltip} - /> - Portalled, and still themed - + {/* The page behind these popups is the colour the translucent panel mixes + from, so without something else under them `Panel` has no visible effect. */} + + + + Tooltip} + /> + Portalled, and still themed + - - Popover} - /> - - - Theme values cross the portal through context, so this popup - matches the scope it was opened from. - - - + + Popover} + /> + + + Theme values cross the portal through context, so this popup + matches the scope it was opened from. + + + + ); diff --git a/apps/www/src/content/docs/theme/preview/demo.ts b/apps/www/src/content/docs/theme/preview/demo.ts index 72f5ea7b2..3bd4537de 100644 --- a/apps/www/src/content/docs/theme/preview/demo.ts +++ b/apps/www/src/content/docs/theme/preview/demo.ts @@ -93,12 +93,14 @@ export const panelBackgroundDemo = { code: ` {["solid", "translucent"].map(panelBackground => ( From 619fb91f8aa6e1542d97dbb4a103ab9fe2767e75 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Fri, 18 Sep 2026 11:16:59 +0530 Subject: [PATCH 08/22] fix(theme): match Radix Themes' translucent panel 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. --- packages/raystack/styles/theme.css | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/raystack/styles/theme.css b/packages/raystack/styles/theme.css index 3b5b711dd..2aa652d2f 100644 --- a/packages/raystack/styles/theme.css +++ b/packages/raystack/styles/theme.css @@ -97,7 +97,7 @@ --rs-color-panel-solid: var(--rs-color-background-base-primary); --rs-color-panel-translucent: color-mix( in oklab, - var(--rs-color-background-base-primary) 80%, + var(--rs-color-panel-solid) 70%, transparent ); --rs-color-panel: var(--rs-color-panel-solid); @@ -105,6 +105,13 @@ --rs-panel-backdrop-filter: none; } +/* Radix Themes' two translucent panels: a 70% wash of the surface in light, + and in dark a faint white lift over the blur rather than a wash, because a + dark panel mixed with the dark page behind it reads as neither. */ +:where([data-theme="dark"]) { + --rs-color-panel-translucent: var(--rs-color-overlay-base-a1); +} + :where([data-panel-background="solid"]) { --rs-color-panel: var(--rs-color-panel-solid); --rs-panel-backdrop-filter: none; From bb543e41083c6d6e313a9b20365e329b53477e6a Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Fri, 18 Sep 2026 11:17:08 +0530 Subject: [PATCH 09/22] refactor(docs): mount the site on ThemePreview The docs shell ran on `next-themes` plus the legacy `ThemeProvider`, which put the tokens on ``. 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. --- apps/www/package.json | 1 - apps/www/src/app/layout.module.css | 3 +- apps/www/src/app/layout.tsx | 5 +- apps/www/src/components/logo/logo.module.css | 2 +- .../theme-switcher/theme-toggle.tsx | 14 ++- apps/www/src/components/theme.module.css | 7 ++ apps/www/src/components/theme.tsx | 88 ++++--------------- .../src/content/docs/theme/overview/index.mdx | 4 + apps/www/src/styles/surfaces.css | 2 +- apps/www/src/styles/typeset.css | 2 +- pnpm-lock.yaml | 29 +++--- 11 files changed, 54 insertions(+), 103 deletions(-) create mode 100644 apps/www/src/components/theme.module.css diff --git a/apps/www/package.json b/apps/www/package.json index f66b19f81..9c2804ba9 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -24,7 +24,6 @@ "fumadocs-ui": "16.0.7", "lucide-react": "^0.548.0", "next": "16.0.7", - "next-themes": "^0.4.4", "prettier": "^2.8.8", "react": "^19.2.1", "react-dom": "^19.2.1", diff --git a/apps/www/src/app/layout.module.css b/apps/www/src/app/layout.module.css index be77bad4c..1911dd0ed 100644 --- a/apps/www/src/app/layout.module.css +++ b/apps/www/src/app/layout.module.css @@ -1,6 +1,7 @@ +/* The ground is painted by the theme element inside, which is where the tokens + now live; `color-scheme` covers the overscroll area past it. */ .body { display: flex; flex-direction: column; min-height: 100vh; - background: var(--rs-color-background-base-primary); } diff --git a/apps/www/src/app/layout.tsx b/apps/www/src/app/layout.tsx index c4844818a..802734a60 100644 --- a/apps/www/src/app/layout.tsx +++ b/apps/www/src/app/layout.tsx @@ -7,7 +7,6 @@ import '@raystack/apsara/style.css'; import '@/styles/base.css'; import '@/styles/typeset.css'; import '@/styles/surfaces.css'; -import { ThemeProvider as NextThemeProvider } from 'next-themes'; import styles from './layout.module.css'; const inter = Inter({ @@ -32,9 +31,7 @@ export default function Layout({ children }: { children: ReactNode }) { - - {children} - + {children} diff --git a/apps/www/src/components/logo/logo.module.css b/apps/www/src/components/logo/logo.module.css index f93c59d73..f97e90972 100644 --- a/apps/www/src/components/logo/logo.module.css +++ b/apps/www/src/components/logo/logo.module.css @@ -9,6 +9,6 @@ font-size: 2rem; font-weight: bold; } -html[data-theme="dark"] .container { +[data-theme="dark"] .container { filter: invert(1); } diff --git a/apps/www/src/components/theme-switcher/theme-toggle.tsx b/apps/www/src/components/theme-switcher/theme-toggle.tsx index 594503c85..86b73cdb9 100644 --- a/apps/www/src/components/theme-switcher/theme-toggle.tsx +++ b/apps/www/src/components/theme-switcher/theme-toggle.tsx @@ -1,21 +1,17 @@ 'use client'; -import { IconButton } from '@raystack/apsara'; +import { IconButton, useThemePreview } from '@raystack/apsara'; import { Moon, Sun } from 'lucide-react'; import { type HTMLAttributes } from 'react'; -import { useTheme } from '@/components/theme'; - -const ICONS_MAP = { light: Sun, dark: Moon } as const; export default function ThemeToggle(props: HTMLAttributes) { - const { setTheme, theme } = useTheme(); - // `theme` can briefly be undefined or an unexpected value during hydration - // (next-themes resolves async). Fall back so rendering doesn't crash. - const Icon = ICONS_MAP[theme as keyof typeof ICONS_MAP] ?? Sun; + const { resolved, setValue } = useThemePreview(); + const isDark = resolved.appearance === 'dark'; + const Icon = isDark ? Moon : Sun; return ( setTheme({ theme: theme === 'light' ? 'dark' : 'light' })} + onClick={() => setValue({ appearance: isDark ? 'light' : 'dark' })} size={3} {...props} > diff --git a/apps/www/src/components/theme.module.css b/apps/www/src/components/theme.module.css new file mode 100644 index 000000000..64f7c0370 --- /dev/null +++ b/apps/www/src/components/theme.module.css @@ -0,0 +1,7 @@ +/* The theme element is the page: it carries the tokens the whole shell reads, + so the column that used to live on `body` lives here. */ +.root { + display: flex; + flex-direction: column; + flex: 1; +} diff --git a/apps/www/src/components/theme.tsx b/apps/www/src/components/theme.tsx index c9abde142..863a21740 100644 --- a/apps/www/src/components/theme.tsx +++ b/apps/www/src/components/theme.tsx @@ -1,76 +1,24 @@ 'use client'; -import { ThemeProvider as ApsaraThemeProvider } from '@raystack/apsara'; -import { useTheme as useNextTheme } from 'next-themes'; -import { - createContext, - ReactNode, - useCallback, - useContext, - useState -} from 'react'; - -type Theme = 'light' | 'dark'; - -export interface ThemeOptions { - /** Style variant of the theme, either 'modern' or 'traditional' */ - style?: 'modern' | 'traditional'; - /** Accent color for the theme */ - accentColor?: 'indigo' | 'orange' | 'mint'; - /** Gray color variant for the theme */ - grayColor?: 'gray' | 'mauve' | 'slate'; - /** Theme value for light or dark */ - theme?: Theme; -} - -interface ThemeContextType extends ThemeOptions { - setTheme: (options: ThemeOptions) => void; -} - -const ThemeContext = createContext(undefined); - -interface ThemeProviderProps { - children: ReactNode; -} - -export function ThemeProvider({ children }: ThemeProviderProps) { - const { resolvedTheme, setTheme } = useNextTheme(); - const theme = (resolvedTheme ?? 'light') as Theme; - - const [options, setOptions] = useState({ - style: 'modern', - accentColor: 'indigo', - grayColor: 'gray' - }); - - const updateOptions = useCallback((options: ThemeOptions) => { - if ('theme' in options && options.theme) setTheme(options.theme); - setOptions(_options => ({ ..._options, ...options })); - }, []); - - const key = `${options?.accentColor}-${options?.grayColor}-${options?.style}`; +import { ThemePreview } from '@raystack/apsara'; +import type { ReactNode } from 'react'; +import styles from './theme.module.css'; + +/** + * The docs' root theme. It is the same component the demos on this site mount, + * so a nested `ThemePreview` inherits appearance from the page rather than + * resolving `system` on its own, and the inline script it emits replaces + * `next-themes` for pre-hydration appearance. + */ +export function ThemeProvider({ children }: { children: ReactNode }) { return ( - - - {children} - - + {children} + ); } - -export function useTheme() { - const context = useContext(ThemeContext); - if (context === undefined) { - throw new Error('useTheme must be used within a ThemeProvider'); - } - return context; -} diff --git a/apps/www/src/content/docs/theme/overview/index.mdx b/apps/www/src/content/docs/theme/overview/index.mdx index ad27c89c4..00f76e568 100644 --- a/apps/www/src/content/docs/theme/overview/index.mdx +++ b/apps/www/src/content/docs/theme/overview/index.mdx @@ -13,6 +13,10 @@ Apsara's theming is built on CSS custom properties, called tokens. A token is a [`ThemePreview`](/docs/theme/preview) — it server-renders, allows more than one provider per page, works inside portals, and adds radius, scaling, panel background and reduced-motion settings. It includes a migration guide. + + This site itself now runs on `ThemePreview`, and the two do not compose: the + `ThemeSwitcher` examples below render, but they write to ``, which the + page's own theme element overrides. Use the toggle in the header instead. ## Installation diff --git a/apps/www/src/styles/surfaces.css b/apps/www/src/styles/surfaces.css index 58b1dbb18..93d4ad272 100644 --- a/apps/www/src/styles/surfaces.css +++ b/apps/www/src/styles/surfaces.css @@ -21,7 +21,7 @@ oversight. */ -:root { +.rs-theme { --docs-bg-outside: var(--rs-color-background-base-secondary); --docs-bg-shell: var(--rs-color-background-base-secondary); --docs-bg-surface: var(--rs-color-background-base-primary); diff --git a/apps/www/src/styles/typeset.css b/apps/www/src/styles/typeset.css index e6564c359..2b9b6fe87 100644 --- a/apps/www/src/styles/typeset.css +++ b/apps/www/src/styles/typeset.css @@ -13,7 +13,7 @@ Sizes stay in px because the values are optical choices, not a modular scale. */ -:root { +.rs-theme { /* Page title: one per page, on the top step of Apsara's heading scale. */ --docs-title-size: var(--rs-font-size-t4); --docs-title-leading: var(--rs-line-height-t4); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a08dce65..103108a9a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,9 +74,6 @@ importers: next: specifier: 16.0.7 version: 16.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - next-themes: - specifier: ^0.4.4 - version: 0.4.4(react-dom@19.2.1(react@19.2.1))(react@19.2.1) prettier: specifier: ^2.8.8 version: 2.8.8 @@ -4519,12 +4516,6 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - next-themes@0.4.4: - resolution: {integrity: sha512-LDQ2qIOJF0VnuVrrMSMLrWGjRMkq+0mpgl6e0juCLqdJ+oo8Q84JRWT6Wh11VDQKkMMe+dVzDKLWs5n87T+PkQ==} - peerDependencies: - react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next-themes@0.4.6: resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} peerDependencies: @@ -9594,7 +9585,7 @@ snapshots: optionalDependencies: next: 16.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1) react: 19.2.1 - vite: 7.1.3(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.39.0) + vite: 7.1.3(@types/node@24.10.0) transitivePeerDependencies: - supports-color @@ -10997,11 +10988,6 @@ snapshots: neo-async@2.6.2: {} - next-themes@0.4.4(react-dom@19.2.1(react@19.2.1))(react@19.2.1): - dependencies: - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - next-themes@0.4.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1): dependencies: react: 19.2.1 @@ -12822,6 +12808,19 @@ snapshots: - tsx - yaml + vite@7.1.3(@types/node@24.10.0): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.48.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.10.0 + fsevents: 2.3.3 + optional: true + vite@7.1.3(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.39.0): dependencies: esbuild: 0.25.5 From 1f6dad05c2d322ff9e25d1c1505b93ca968272e8 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Fri, 18 Sep 2026 11:51:26 +0530 Subject: [PATCH 10/22] docs(theme): fold ThemePreview into the theme overview 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. --- .../docs/(overview)/getting-started.mdx | 41 +- .../www/src/content/docs/(overview)/index.mdx | 8 +- .../src/content/docs/(overview)/styling.mdx | 13 +- .../src/content/docs/(overview)/upgrading.mdx | 103 ++++ apps/www/src/content/docs/theme/meta.json | 1 - .../src/content/docs/theme/overview/demo.ts | 309 +++++++++++- .../src/content/docs/theme/overview/index.mdx | 443 +++++++----------- .../src/content/docs/theme/overview/props.ts | 168 ++++--- .../src/content/docs/theme/preview/demo.ts | 311 ------------ .../src/content/docs/theme/preview/index.mdx | 362 -------------- .../src/content/docs/theme/preview/props.ts | 144 ------ 11 files changed, 709 insertions(+), 1194 deletions(-) delete mode 100644 apps/www/src/content/docs/theme/preview/demo.ts delete mode 100644 apps/www/src/content/docs/theme/preview/index.mdx delete mode 100644 apps/www/src/content/docs/theme/preview/props.ts diff --git a/apps/www/src/content/docs/(overview)/getting-started.mdx b/apps/www/src/content/docs/(overview)/getting-started.mdx index 58df9de1a..e61720479 100644 --- a/apps/www/src/content/docs/(overview)/getting-started.mdx +++ b/apps/www/src/content/docs/(overview)/getting-started.mdx @@ -48,23 +48,23 @@ import "@raystack/apsara/style.css"; This single stylesheet includes all component styles and CSS custom properties (tokens) for theming. -### 2. Add Theme +### 2. Add ThemePreview -Wrap your application with `Theme` to enable theming support: +Wrap your application with `ThemePreview` to enable theming support: ```tsx -import { Theme } from "@raystack/apsara"; +import { ThemePreview } from "@raystack/apsara"; function App() { return ( - + - + ); } ``` -The `defaultTheme` prop accepts `"light"`, `"dark"`, or `"system"` (follows OS preference). +Tokens live on the element it renders, so anything that reads `--rs-*` has to be inside it. `persistKey` stores the user's choices; leave it off to keep them in memory. Seed any of the seven settings with `defaultValue`, for example `defaultValue={{ appearance: "dark" }}`. ### 3. Use components @@ -96,23 +96,23 @@ Add the provider to your root layout: ```tsx // app/layout.tsx -import { Theme } from "@raystack/apsara"; +import { ThemePreview } from "@raystack/apsara"; import "@raystack/apsara/style.css"; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - + - + {children} - + ); } ``` -The `suppressHydrationWarning` attribute is required because `Theme` injects a script to prevent theme flash during hydration. +Settings are props, so the server renders them. An inline script patches in what it cannot know before first paint: stored values, and the OS answer for a `system` appearance. Nothing is written to ``, so it needs no `suppressHydrationWarning`. ### Vite @@ -122,15 +122,15 @@ Add the provider to your main entry file: // main.tsx import React from "react"; import ReactDOM from "react-dom/client"; -import { Theme } from "@raystack/apsara"; +import { ThemePreview } from "@raystack/apsara"; import "@raystack/apsara/style.css"; import App from "./App"; ReactDOM.createRoot(document.getElementById("root")!).render( - + - + ); ``` @@ -172,17 +172,18 @@ function CopyButton({ value }: { value: string }) { } ``` -To read or change the active theme, use `useTheme` from the main entry: +To read or change the active theme, use `useThemePreview` from the main entry: ```tsx -import { Button, useTheme } from "@raystack/apsara"; +import { Button, useThemePreview } from "@raystack/apsara"; function ThemeToggle() { - const { resolvedTheme, setTheme } = useTheme(); + const { resolved, setValue } = useThemePreview(); + const isDark = resolved.appearance === "dark"; return ( - ); } @@ -190,6 +191,6 @@ function ThemeToggle() { ## Next steps -- [Theme Overview](/docs/theme/overview): configure colors, spacing, and style variants +- [Theme Overview](/docs/theme/overview): tokens, the seven settings, and nested scopes - [Button](/docs/components/button): start with a common component - [DataView](/docs/dataview): build data-rich interfaces diff --git a/apps/www/src/content/docs/(overview)/index.mdx b/apps/www/src/content/docs/(overview)/index.mdx index 60017749b..2c9490ceb 100644 --- a/apps/www/src/content/docs/(overview)/index.mdx +++ b/apps/www/src/content/docs/(overview)/index.mdx @@ -30,14 +30,14 @@ Apsara provides over 60 components organized by function: ## Theming -The theming system uses CSS custom properties (tokens) that automatically adapt to the active theme. Wrap your app with `Theme` to enable light/dark modes, accent colors, and style variants. +The theming system uses CSS custom properties (tokens) that automatically adapt to the active theme. Wrap your app with `ThemePreview` to enable appearance, accent and gray colors, radius, scaling, panel background and reduced motion. ```tsx -import { Theme } from "@raystack/apsara"; +import { ThemePreview } from "@raystack/apsara"; - + - + ``` Tokens follow a consistent naming convention: diff --git a/apps/www/src/content/docs/(overview)/styling.mdx b/apps/www/src/content/docs/(overview)/styling.mdx index dd4a4e876..d075e435f 100644 --- a/apps/www/src/content/docs/(overview)/styling.mdx +++ b/apps/www/src/content/docs/(overview)/styling.mdx @@ -160,7 +160,7 @@ Coverage: every Apsara component exposes `data-slot` on every element it renders ## Theming with data attributes -The `Theme` component sets data attributes on the root `` element. Use these to conditionally style elements based on the active theme: +`ThemePreview` writes one data attribute per setting onto the element it renders. Match an ancestor rather than ``, so a nested scope styles correctly too: ```css /* Dark mode specific styles */ @@ -168,9 +168,9 @@ The `Theme` component sets data attributes on the root `` element. Use the border-color: var(--rs-color-border-base-tertiary); } -/* Traditional style variant */ -[data-style="traditional"] .custom-heading { - font-family: var(--rs-font-family-serif); +/* Only when the theme squares its corners */ +[data-radius="none"] .custom-heading { + letter-spacing: 0; } ``` @@ -179,9 +179,12 @@ Available theme attributes: | Attribute | Values | |-----------|--------| | `data-theme` | `light`, `dark` | -| `data-style` | `modern`, `traditional` | | `data-accent-color` | `indigo`, `orange`, `mint` | | `data-gray-color` | `gray`, `mauve`, `slate`, `sage` | +| `data-radius` | `none`, `small`, `medium`, `large`, `full` | +| `data-scaling` | `0.9`, `0.95`, `1`, `1.05`, `1.1` | +| `data-panel-background` | `solid`, `translucent` | +| `data-reduced-motion` | `true`, `false` | ## Writing component styles diff --git a/apps/www/src/content/docs/(overview)/upgrading.mdx b/apps/www/src/content/docs/(overview)/upgrading.mdx index f4a51c02d..269b7863c 100644 --- a/apps/www/src/content/docs/(overview)/upgrading.mdx +++ b/apps/www/src/content/docs/(overview)/upgrading.mdx @@ -7,6 +7,109 @@ One section per release, newest first, with only the changes that need action from you. The full record of every release, features and fixes included, is on [GitHub releases](https://github.com/raystack/apsara/releases). +## 2.0: `ThemePreview` replaces `Theme` + +`Theme` put its tokens on `` from an effect, so it could not server-render +and only one could exist per page. [`ThemePreview`](/docs/theme/overview) mounts +them on an element it renders instead, which makes a scope, a portalled popup +and the root the same component, and adds radius, scaling, panel background and +reduced-motion settings. + +Migrate the whole application at once. The two do not compose: whichever owns +the root wins, and tokens written to `` are overridden by any +`ThemePreview` below it. + +### 1. Move the provider + +```tsx +// Before + track(resolved)} +> + + + +// After + { + if (changed.appearance) track(value.appearance); + }} +> + + +``` + +`persistKey` now gates persistence rather than only naming it: without one, +nothing is stored. It also ignores the bare theme name `Theme` wrote, so a +returning user starts from the seed once. + +### 2. Rename the props + +| Removed | Replacement | +|---|---| +| `theme`, `forcedTheme` | `value.appearance` | +| `defaultTheme` | `defaultValue.appearance` | +| `accentColor`, `grayColor` as flat props | `defaultValue.accentColor`, `defaultValue.grayColor` | +| `style` | `radius` plus the `--rs-font-*` tokens | +| `onThemeChange` | `onValueChange` | +| `enableSystem` | `appearance: "system"` | +| `enableColorScheme` | Handled by the stylesheet | +| `storageKey` | `persistKey` | +| `themes`, `attribute`, `value` as a name-to-attribute map | None. Arbitrary named themes are not supported | +| `ThemeProvider` alias | `ThemePreview` | +| `ThemeSwitcher` | `ThemePreviewSwitcher` | + +`style="modern" | "traditional"` becomes a radius level plus a font pair: +`defaultValue={{ radius: "large" }}` with `--rs-font-title` and `--rs-font-body` +set in CSS. + +### 3. Rename the hook + +| Removed | Replacement | +|---|---| +| `useTheme().theme` / `.setTheme` / `.resolvedTheme` / `.systemTheme` | `value` / `setValue` / `resolved` / `systemAppearance` | +| `useTheme().themes` / `.forcedTheme` / `.style` / `.scopes` | None | +| `useTheme({ storageKey })` | `useThemePreview().root` | + +```tsx +// Before: force dark for a subtree + +// After + + +// Before: flip the page theme from inside a scope +const { setTheme } = useTheme({ storageKey: "theme" }); +// After +const { root } = useThemePreview(); +root.setValue({ appearance: "dark" }); +``` + +`useThemePreview` throws outside a provider instead of returning a no-op. + +### 4. Move anything that reads tokens inside the provider + +Tokens are no longer on ``, so consumer CSS that declared custom +properties on `:root` from `--rs-*` values, and hand-rolled portals that +rendered outside the provider, have to move inside it. `` no longer needs +`suppressHydrationWarning`. + + + `Image` and `Avatar` now use the shared five radius values. `Image` gains + `large`. `Avatar` has no default radius and follows the theme, so pass + `radius="full"` to keep circles. + + ## 1.6: lucide replaces the radix icons Apsara used to draw its icons with [`@radix-ui/react-icons`](https://www.radix-ui.com/icons). diff --git a/apps/www/src/content/docs/theme/meta.json b/apps/www/src/content/docs/theme/meta.json index 067f5f840..47be1c910 100644 --- a/apps/www/src/content/docs/theme/meta.json +++ b/apps/www/src/content/docs/theme/meta.json @@ -2,7 +2,6 @@ "title": "Theme", "pages": [ "overview", - "preview", "colors", "typography", "spacing", diff --git a/apps/www/src/content/docs/theme/overview/demo.ts b/apps/www/src/content/docs/theme/overview/demo.ts index 190cda603..3bd4537de 100644 --- a/apps/www/src/content/docs/theme/overview/demo.ts +++ b/apps/www/src/content/docs/theme/overview/demo.ts @@ -1,16 +1,311 @@ 'use client'; -export const switcherDemo = { +export const panelDemo = { + type: 'code', + code: `` +}; + +export const appearanceDemo = { + type: 'code', + code: ` + + {["light", "dark"].map(appearance => ( + + + {appearance} + + + + + ))} + ` +}; + +export const accentDemo = { type: 'code', - code: `` + code: ` + + {["indigo", "orange", "mint"].map(accent => ( + + + {accent} + + Badge + + + ))} + ` }; -export const switcherSizeDemo = { +export const radiusDemo = { type: 'code', code: ` - - - - + + {["none", "small", "medium", "large", "full"].map(radius => ( + + + {radius} + + + + + ))} ` }; + +export const scalingDemo = { + type: 'code', + code: ` + + {["0.9", "1", "1.1"].map(scaling => ( + + + {scaling}x + + + + ))} + ` +}; + +export const panelBackgroundDemo = { + type: 'code', + code: ` + + {["solid", "translucent"].map(panelBackground => ( + + {/* The same two tokens every overlay surface uses */} + + {panelBackground} + + Open popover} /> + + The popup uses the same surface. + + + + + ))} + ` +}; + +export const nestingDemo = { + type: 'code', + code: ` + + + + indigo, medium + + + + + {/* Sets accent and radius; inherits appearance */} + + + + mint, full + + + + + {/* Sets only the accent; inherits the full radius */} + + + orange, inherited full + + + + + + + + ` +}; + +export const layoutDemo = { + type: 'code', + code: ` + + + {/* A dark scope paints its own background */} + + + + + + + + + + Inbox + + + + + + + + ` +}; + +export const portalDemo = { + type: 'code', + code: ` + + + + Popover} /> + + Rendered in a portal, themed by the scope. + + + + + + + Tooltip} /> + Dark, like its trigger + + + ` +}; + +export const controlledDemo = { + type: 'code', + code: ` +function ControlledScope() { + const [dark, setDark] = React.useState(false); + + return ( + + + + Dark + + + + + Controlled by the switch + + + + + ); +}` +}; + +export const componentRadiusDemo = { + type: 'code', + code: ` + + + + {/* Overrides the theme without compounding */} + + + + + ` +}; + +export const switcherDemo = { + type: 'code', + code: ` + + + + Flips this scope + + ` +}; diff --git a/apps/www/src/content/docs/theme/overview/index.mdx b/apps/www/src/content/docs/theme/overview/index.mdx index 00f76e568..ba6e6c3fe 100644 --- a/apps/www/src/content/docs/theme/overview/index.mdx +++ b/apps/www/src/content/docs/theme/overview/index.mdx @@ -1,385 +1,296 @@ --- title: Overview -description: Understanding the Apsara theming system and the Theme component. +description: Theming with ThemePreview. Tokens, seven settings, nested scopes, and portals that follow. --- -import { switcherDemo, switcherSizeDemo } from "./demo.ts"; +import { + accentDemo, + appearanceDemo, + componentRadiusDemo, + controlledDemo, + layoutDemo, + nestingDemo, + panelBackgroundDemo, + panelDemo, + portalDemo, + radiusDemo, + scalingDemo, + switcherDemo +} from "./demo.ts"; -Apsara's theming is built on CSS custom properties, called tokens. A token is a semantic variable that resolves to the right value for the active theme, so the UI follows along when someone switches between light and dark or you change the accent color. No code changes needed. + - - This page documents the original `Theme` component, which continues to ship - unchanged. New applications should use - [`ThemePreview`](/docs/theme/preview) — it server-renders, allows more than - one provider per page, works inside portals, and adds radius, scaling, panel - background and reduced-motion settings. It includes a migration guide. +Apsara's theming is built on CSS custom properties, called tokens. `ThemePreview` mounts them on a real element, so the root theme, a nested scope and a portalled popup are all the same component. It server-renders, more than one can exist per page, and a popup opened inside a scope keeps that scope's theme. - This site itself now runs on `ThemePreview`, and the two do not compose: the - `ThemeSwitcher` examples below render, but they write to ``, which the - page's own theme element overrides. Use the toggle in the header instead. - - -## Installation - -Wrap your application with the `Theme` component: +## Usage ```tsx -import { Theme } from "@raystack/apsara"; +import { ThemePreview } from "@raystack/apsara"; -function App() { +export default function App() { return ( - + - + ); } ``` -## Customization - -The `Theme` component accepts props to control the visual identity of your application. Combine `style` variants with `accentColor` and `grayColor` to create distinct aesthetics, from sharp and technical to warm and editorial. The `defaultTheme` prop controls light/dark mode, with `system` respecting the user's OS preference. - -```tsx -// Clean, technical aesthetic - - -// Warm, editorial feel - - -// Vibrant and fresh - -``` - -See [API Reference](#api-reference) for all available props and options. - -## Tokens - -Tokens follow two naming patterns: +Tokens live on the element `ThemePreview` renders, so anything that reads `--rs-*` must be inside it. Portalled components re-emit the theme onto their popups; only your own portals need to move inside. -**Semantic tokens** hold context-aware values that adapt to the theme: -``` ---rs-{category}-{property}-{variant}-{state} -``` +## Settings -**Scale tokens** hold numerical progressions: -``` ---rs-{category}-{step} -``` +Every setting is a key of one object. Each key can be seeded, controlled or persisted on its own, and each becomes a data attribute on the theme element. -**Examples:** -- `--rs-color-foreground-base-primary`: primary text color -- `--rs-color-background-accent-emphasis`: accent button background -- `--rs-space-5`: 16px spacing -- `--rs-radius-3`: medium border radius -- `--rs-shadow-lifted`: elevated shadow +| Setting | Values | Default | Attribute | +|---|---|---|---| +| `appearance` | `light`, `dark`, `system` | `system` | `data-theme` | +| `accentColor` | `indigo`, `orange`, `mint` | `indigo` | `data-accent-color` | +| `grayColor` | `gray`, `mauve`, `slate`, `sage`, `auto` | `auto` | `data-gray-color` | +| `radius` | `none`, `small`, `medium`, `large`, `full` | `medium` | `data-radius` | +| `scaling` | `0.9`, `0.95`, `1`, `1.05`, `1.1` | `1` | `data-scaling` | +| `panelBackground` | `solid`, `translucent` | `solid` | `data-panel-background` | +| `reducedMotion` | `true`, `false`, `system` | `system` | `data-reduced-motion` | -**Using tokens in CSS:** +`system` and `auto` are resolved before the attribute is written, so `data-theme` is always `light` or `dark`. Fonts are CSS variables, not a setting; see [Fonts](#fonts). -```css -.custom-card { - background: var(--rs-color-background-base-secondary); - border: 1px solid var(--rs-color-border-base-primary); - border-radius: var(--rs-radius-4); - padding: var(--rs-space-5); - box-shadow: var(--rs-shadow-feather); -} -``` +### Appearance -**Token Categories:** -- [Colors](/docs/theme/colors): foreground, background, border, and overlay colors -- [Spacing](/docs/theme/spacing): consistent scale from 2px to 120px -- [Radius](/docs/theme/radius): border radius that adapts to style variants -- [Typography](/docs/theme/typography): font families, sizes, weights, and line heights -- [Effects](/docs/theme/effects): shadows and blur for depth and elevation + -## Framework integration +### Accent color -**HTML attributes.** `Theme` sets data attributes on the document element for CSS targeting: -- `data-theme`: current color scheme (`light` | `dark`) -- `data-style`: active style variant (`modern` | `traditional`) -- `data-accent-color`: active accent color (`indigo` | `orange` | `mint`) -- `data-gray-color`: active gray variant (`gray` | `mauve` | `slate`) +`grayColor: "auto"` pairs a gray to the accent. -**SSR and flash prevention.** `Theme` includes an inline script that runs before React hydration to prevent flash of incorrect theme. For SSR frameworks, include the provider in your root layout: + -```tsx -// Next.js App Router: app/layout.tsx -import { Theme } from "@raystack/apsara"; +### Radius -export default function RootLayout({ children }) { - return ( - - - {children} - - - ); -} -``` +A factor over a fixed base scale. Controls such as `Button` become pills only at `full`; round controls such as `Switch` stay round from `medium` up and square off at `none` and `small`. Surfaces never become pills. -The `suppressHydrationWarning` is required because the theme script modifies the HTML element before React hydrates. + -## Scoped theming +### Scaling -Themes are not limited to the document root. Any element with a `data-theme` attribute creates an isolated theme scope. Descendants resolve every design token from the nearest scoped ancestor. This enables theme preview cards, split-screen comparisons, and dark sidebars in light apps without any extra plumbing. +A zoom: spacing, radius, type and line height scale together. Borders and font weights do not. -### Bare attribute + -Scoping is implemented in CSS, so setting the attribute on any element opts in: +### Panel background -```tsx - - {/* Page is dark */} -
- {/* This subtree renders with light tokens */} - -
- -``` +Overlay surfaces are opaque by default. `translucent` blurs what is behind dialogs, drawers, menus, popovers, selects, tooltips and toasts, and tints them with it: a 70% wash of the surface in light, and in dark a faint lift that lets the blur carry the panel. -The package's stylesheet handles the rest: every `--rs-color-*` token, `color-scheme` for native form controls and scrollbars, and the smooth transition during theme switches all follow the scoped attribute. + -### Nested `Theme` +A translucent panel only reads as translucent against something other than itself. Over a plain page, which is the colour the panel is drawn from, it looks identical to `solid`. -Render a `Theme` inside another `Theme` and the inner one switches to *scope mode*. +### Reduced motion -For a typed convenience wrapper, nest `Theme`: +`system` follows `prefers-reduced-motion`. `"true"` collapses the duration tokens, which stops transitions and any animation timed by a token. ```tsx -import { Theme } from "@raystack/apsara"; - - - Dark scoped card - + ``` -When `Theme` is rendered inside another `Theme`, it switches to scope mode: it writes `data-theme` (and optionally `data-accent-color`, `data-gray-color`, `data-style`) onto a wrapper `
` rather than the document root. `useTheme()` still reports the outer provider's state. - -### Inheritance rules +## Nesting -Two rules cover every case: +A nested `ThemePreview` inherits every key it does not set. -1. **Each prop inherits independently.** Any prop you don't pass to a nested `Theme` is inherited from the parent. Any prop you do pass overrides only that field. The rest still inherits. -2. **`useTheme()` inside a scope talks to that scope only.** Calling `setTheme()` updates the nearest scope, never propagates outward. To target a specific outer scope (e.g., the root), use `useTheme({ storageKey })`. See [Targeting a specific scope](#targeting-a-specific-scope). + -### Activating a scope +A scope with its own `light` or `dark` appearance paints its background. One that only changes accent, gray, radius or scaling is transparent. `hasBackground` overrides either. -A nested `` becomes an *active scope* (owns state, renders a wrapper, provides its own context) when you pass at least one of: `forcedTheme`, `defaultTheme`, `accentColor`, `grayColor`, `style`, or `storageKey`. A bare `` with no props is a no-op pass-through. Children render with the parent's context. + -If you want a section to act as a scope but don't need to override any specific token, pass `defaultTheme` (seeds an initial scope theme): +### Portals -```tsx - - {/* This is now a stateless scope. setTheme inside updates this scope only. */} - -``` +Theme values travel through React context, so every portalling component re-emits them onto its popup. A menu opened inside a dark scope is dark, with nothing to configure. -### Composition examples + -The cases below assume a configured page-level `Theme` and progressively richer nested overrides. +### isRoot -**Both fully configured, independent states** +One theme per document owns the page colour scheme: the scrollbar, the overscroll area and native widget defaults. The first theme with no ancestor claims it. An embedded widget that has no ancestor theme but does not own the page must pass `isRoot={false}`. ```tsx - - - - - + + + ``` -- Page: dark + orange + mauve + traditional. Persisted under `storageKey="theme"`. -- Scope: light + mint + slate + modern. Owns its state in memory (no `storageKey`, so not persisted). -- `Card` renders with the scope's values. -- Toggling the page does not move the scope; toggling the scope does not move the page. +### render -**Partial override, single prop** +Merge the theme onto your own element instead of adding a wrapper: ```tsx - - - - - +}> + + ``` -What `Card` sees: - -| Field | Source | Value | -|---|---|---| -| `resolvedTheme` | inherited from page | `dark` | -| `accentColor` | own (scope) | `mint` | -| `grayColor` | inherited | `mauve` | -| `style` | inherited | `traditional` | +## Controlled -- The wrapper `
` gets `data-theme="dark" data-accent-color="mint" data-gray-color="mauve" data-style="traditional"` so CSS rules like `[data-accent-color='mint'][data-theme='dark']` match correctly. -- If the page theme later flips to light, the scope follows (still inheriting `theme`). Accent stays `mint`. -- If the scope's own `setTheme()` is called, the scope decouples from the page for theme only; non-overridden fields keep inheriting. +`defaultValue` seeds a key; `value` controls it. A controlled key always wins, is never persisted and is never written by the inline script. Control is per key. -**Display-locked region with `forcedTheme`** + ```tsx - - - - - +// Appearance from a cookie; accent stays adjustable and persisted + + + ``` -- `PreviewCard` and its descendants always render with the light theme, regardless of any toggle. -- Accent / gray / style still inherit from the page. -- `setTheme()` inside still updates the scope's stored value, but `forcedTheme` always wins for display. +## Persistence -**Persistent independent island with `storageKey`** +Off unless `persistKey` is set. A theme without one keeps its settings in memory, so scopes and embedded widgets never collide. `persist` narrows which keys the namespace stores. ```tsx - - - - {/* setTheme here updates and persists the sidebar's own theme */} - - - +// Everything under one namespace + + +// Only the appearance + ``` -- Two independent persisted entries: `localStorage["theme"]` (page) and `localStorage["sidebar-theme"]` (sidebar). -- Reloading the page restores both to their last-saved values. -- A toggle inside the sidebar flips only the sidebar. +Themes sharing a `persistKey` stay in step, across tabs as well. Writes merge into the stored object, so themes with different `persist` lists can share one key. + +## Server rendering -**Empty nested, no scope created** +Settings are props, so the server renders them. An inline script, the theme element's first child, patches in what the server cannot know before first paint: stored values, and the OS answer for a `system` appearance. A pinned appearance with no `persistKey` ships no script. Pass `nonce` if your CSP needs one. ```tsx - - {/* no props → no-op pass-through */} - - - -``` +// Next.js App Router: app/layout.tsx +import { ThemePreview } from "@raystack/apsara"; -- The inner `Theme` does nothing: no wrapper, no new context. -- `useTheme()` inside `Card` returns the page's state. -- `setTheme()` inside `Card` flips the page. +export default function RootLayout({ children }) { + return ( + + + {children} + + + ); +} +``` -### Persistent scope +Nothing is written to ``, so it needs no `suppressHydrationWarning`. -Pass `storageKey` to a nested `Theme` and it becomes stateful, persisting the scope's theme to localStorage. Descendants read and update it through the same `useTheme()` hook. It returns the nearest provider's state, so inside a persistent scope it returns the scope's theme and setter: +## useThemePreview ```tsx -import { Theme, useTheme } from "@raystack/apsara"; +import { useThemePreview } from "@raystack/apsara"; -function ScopeToggle() { - const { theme, setTheme } = useTheme(); +function AppearanceToggle() { + const { resolved, setValue } = useThemePreview(); + const isDark = resolved.appearance === "dark"; return ( - ); } - - - - - ``` -**Behavior:** - -- On mount, the scope reads `localStorage[storageKey]`. If present, that value wins. Otherwise the scope uses `defaultTheme`. If neither is set, the scope inherits from its parent (no `data-theme` on the wrapper). -- Inside the scope, `useTheme()` returns layered state: scope-owned fields (`theme`, `setTheme`) come from the scope; the rest (`themes`, `systemTheme`, etc.) are inherited from the root. -- `setTheme(value)` updates state and writes to the scope's localStorage key. -- `setTheme(undefined)` clears the storage entry and re-inherits from the parent. -- Changes from other tabs propagate automatically via the `storage` event. -- `forcedTheme`, if passed, wins over storage for display but is **not** persisted. It is a developer override, not a user choice. + -**Gotchas:** +`value` is what was set, `system` and `auto` included; `resolved` is what is on screen. `root` is the same handle bound to the root provider, so a control inside a scope can change the page: -- Use a distinct `storageKey` per scope. Multiple scopes sharing one key is undefined behavior within the same tab. -- There is no FOUC prevention for nested scopes. On reload, the scope renders with `defaultTheme` (or inherits) for one paint, then snaps to the saved value once React hydrates. For above-the-fold scopes you'll see a brief flash. The root provider's inline script protects `` only; per-scope inline scripts are not emitted in this version. +```tsx +const { root } = useThemePreview(); +root.setValue({ appearance: "dark" }); +``` -### Targeting a specific scope +The hook throws outside a provider. -`useTheme()` always talks to the *nearest* scope. To target a specific outer scope (typically the root, so a deep button can flip the whole page), pass its `storageKey`: +### ThemePreviewSwitcher -```tsx -import { useTheme } from "@raystack/apsara"; +An icon button that flips between light and dark. It follows `resolved.appearance`. -function PageThemeButton() { - // Reaches past nearer scopes to the root (whose default storageKey is "theme"). - const { theme, setTheme } = useTheme({ storageKey: "theme" }); - return ( - - ); -} -``` + -- If a matching scope is found, the hook returns its `theme` + `setTheme`; the rest of the fields still reflect the nearest scope. -- If no scope with that `storageKey` exists in the ancestor tree, the hook falls back to the nearest scope (same as calling `useTheme()` with no argument). + -### Cheat sheet +## Per-component radius -Every nesting case in one table. +Components accept a `radius` prop with the theme's five values. It affects only that component and does not compound with the theme radius. -| What you want | What to pass on the nested `Theme` | -|---|---| -| Section is purely styled, no own state | Don't nest. There's nothing to scope. | -| Section has its own theme, in-memory only | `defaultTheme="dark"` (any value) | -| Section is locked to a theme regardless of toggles | `forcedTheme="dark"` | -| Section overrides only accent / gray / style | Just pass those props; theme inherits | -| Section persists its own theme across reloads | `storageKey="some-key"` (+ optional `defaultTheme`) | -| Flip the page theme from inside a scope | `useTheme({ storageKey: "theme" }).setTheme(…)` | + -### Nested provider vs. bare attribute +Available on `Button`, `IconButton`, `Badge`, `Callout`, `Chip`, `Input`, `TextArea`, `Image`, `Avatar`, and on the portalled parts `Dialog.Content`, `AlertDialog.Content`, `Drawer.Content`, `Popover.Content`, `Menu.Content`, `ContextMenu.Content`, `Select.Content`, `Combobox.Content`, `Tooltip.Content`, `PreviewCard.Content`, `Command.DialogContent` and `Tour.Content`. It goes on the portalled part, not the root: ``. -- Use the **bare `data-theme` attribute** when you're already rendering a custom element and don't want another wrapper. The CSS handles everything, so components inside will theme correctly. -- Use a **nested `Theme`** when you want typed props (`forcedTheme`, `accentColor`, etc.), automatic inheritance of unspecified fields, and `useTheme()` integration. +## Tokens -## API Reference +Tokens are named by what they mean, not what they look like. Semantic tokens carry a context-aware value; scale tokens carry a step in a numeric progression. -The provider, the hook, and the ready-made switcher. +``` +--rs-{category}-{property}-{variant}-{state} --rs-color-background-accent-emphasis +--rs-{category}-{step} --rs-space-5, --rs-radius-3 +``` -### Theme +```css +.custom-card { + background: var(--rs-color-background-base-secondary); + border: 1px solid var(--rs-color-border-base-primary); + border-radius: var(--rs-radius-4); + padding: var(--rs-space-5); + box-shadow: var(--rs-shadow-feather); +} +``` -The `Theme` component wraps your application and manages theme state. It handles persisting the user's preference to localStorage, syncing with system preferences, and injecting the appropriate CSS variables into the document. +The full reference is split by category: [colors](/docs/theme/colors), [typography](/docs/theme/typography), [spacing](/docs/theme/spacing), [radius](/docs/theme/radius), [effects](/docs/theme/effects) and [icons](/docs/theme/icons). - +### Overriding -### useTheme +Every `--rs-*` declaration is wrapped in `:where()` and every theme element carries the `rs-theme` class, so one class selector overrides any token without `!important`: -The `useTheme` hook provides access to the current theme state and methods to change it. Use this to build theme toggles, read the resolved theme for conditional rendering, or sync with external systems. +```css +.rs-theme { + --rs-color-background-accent-emphasis: #6d28d9; + --rs-radius-3: 10px; +} -```tsx -import { useTheme } from "@raystack/apsara"; +.marketing-page .rs-theme { + --rs-font-title: "Playfair Display", serif; +} +``` -function ThemeToggle() { - const { theme, setTheme, resolvedTheme } = useTheme(); +Inline `style` works too: - return ( - - ); -} +```tsx + ``` -The hook accepts an optional options object to target a specific scope by its `storageKey` (see [Targeting a specific scope](#targeting-a-specific-scope)): +### Fonts - +Three CSS variables, no prop: -And returns: +| Token | Role | +|---|---| +| `--rs-font-body` | Body text | +| `--rs-font-title` | Headings | +| `--rs-font-mono` | Monospace | - +```css +.rs-theme { + --rs-font-body: "Geist", system-ui, sans-serif; + --rs-font-title: "Geist", system-ui, sans-serif; +} +``` -### ThemeSwitcher +Import one stylesheet: `@raystack/apsara/style.css` includes the font imports, `@raystack/apsara/style-no-fonts.css` leaves them out for self-hosted fonts. The type scale is tuned for Inter, so another font may need its line heights and tracking adjusted. -A ready-made icon button that toggles between light and dark. It calls `useTheme` internally, so it must render inside a `Theme` provider. The button is a native ` - - - - ))} - ` -}; - -export const accentDemo = { - type: 'code', - code: ` - - {["indigo", "orange", "mint"].map(accent => ( - - - {accent} - - Badge - - - ))} - ` -}; - -export const radiusDemo = { - type: 'code', - code: ` - - {["none", "small", "medium", "large", "full"].map(radius => ( - - - {radius} - - - - - ))} - ` -}; - -export const scalingDemo = { - type: 'code', - code: ` - - {["0.9", "1", "1.1"].map(scaling => ( - - - {scaling}x - - - - ))} - ` -}; - -export const panelBackgroundDemo = { - type: 'code', - code: ` - - {["solid", "translucent"].map(panelBackground => ( - - {/* The same two tokens every overlay surface uses */} - - {panelBackground} - - Open popover} /> - - The popup uses the same surface. - - - - - ))} - ` -}; - -export const nestingDemo = { - type: 'code', - code: ` - - - - indigo, medium - - - - - {/* Sets accent and radius; inherits appearance */} - - - - mint, full - - - - - {/* Sets only the accent; inherits the full radius */} - - - orange, inherited full - - - - - - - - ` -}; - -export const layoutDemo = { - type: 'code', - code: ` - - - {/* A dark scope paints its own background */} - - - - - - - - - - Inbox - - - - - - - - ` -}; - -export const portalDemo = { - type: 'code', - code: ` - - - - Popover} /> - - Rendered in a portal, themed by the scope. - - - - - - - Tooltip} /> - Dark, like its trigger - - - ` -}; - -export const controlledDemo = { - type: 'code', - code: ` -function ControlledScope() { - const [dark, setDark] = React.useState(false); - - return ( - - - - Dark - - - - - Controlled by the switch - - - - - ); -}` -}; - -export const componentRadiusDemo = { - type: 'code', - code: ` - - - - {/* Overrides the theme without compounding */} - - - - - ` -}; - -export const switcherDemo = { - type: 'code', - code: ` - - - - Flips this scope - - ` -}; diff --git a/apps/www/src/content/docs/theme/preview/index.mdx b/apps/www/src/content/docs/theme/preview/index.mdx deleted file mode 100644 index 18a1e8e8b..000000000 --- a/apps/www/src/content/docs/theme/preview/index.mdx +++ /dev/null @@ -1,362 +0,0 @@ ---- -title: ThemePreview -description: Mounts the theme on an element. Seven settings, nested scopes, and portals that follow. ---- - -import { - accentDemo, - appearanceDemo, - componentRadiusDemo, - controlledDemo, - layoutDemo, - nestingDemo, - panelBackgroundDemo, - panelDemo, - portalDemo, - radiusDemo, - scalingDemo, - switcherDemo -} from "./demo.ts"; - - - -`ThemePreview` is the next `Theme`. It renders a real element that carries every token, so the root theme, a nested scope and a portalled popup all use the same component. It server-renders, more than one can exist per page, and a popup opened inside a scope keeps that scope's theme. - - - `ThemePreview` is additive. `Theme`, `useTheme` and `ThemeSwitcher` are unchanged. Use one or the other per application; do not nest them. - - -## Usage - -```tsx -import { ThemePreview } from "@raystack/apsara"; - -export default function App() { - return ( - - - - ); -} -``` - -Tokens live on the element `ThemePreview` renders, so anything that reads `--rs-*` must be inside it. Portalled components re-emit the theme onto their popups; only your own portals need to move inside. - -## Settings - -Every setting is a key of one object. Each key can be seeded, controlled or persisted on its own, and each becomes a data attribute on the theme element. - -| Setting | Values | Default | Attribute | -|---|---|---|---| -| `appearance` | `light`, `dark`, `system` | `system` | `data-theme` | -| `accentColor` | `indigo`, `orange`, `mint` | `indigo` | `data-accent-color` | -| `grayColor` | `gray`, `mauve`, `slate`, `sage`, `auto` | `auto` | `data-gray-color` | -| `radius` | `none`, `small`, `medium`, `large`, `full` | `medium` | `data-radius` | -| `scaling` | `0.9`, `0.95`, `1`, `1.05`, `1.1` | `1` | `data-scaling` | -| `panelBackground` | `solid`, `translucent` | `solid` | `data-panel-background` | -| `reducedMotion` | `true`, `false`, `system` | `system` | `data-reduced-motion` | - -`system` and `auto` are resolved before the attribute is written, so `data-theme` is always `light` or `dark`. Fonts are CSS variables, not a setting; see [Fonts](#fonts). - -### Appearance - - - -### Accent color - -`grayColor: "auto"` pairs a gray to the accent. - - - -### Radius - -A factor over a fixed base scale. Controls such as `Button` become pills only at `full`; round controls such as `Switch` stay round from `medium` up and square off at `none` and `small`. Surfaces never become pills. - - - -### Scaling - -A zoom: spacing, radius, type and line height scale together. Borders and font weights do not. - - - -### Panel background - -Overlay surfaces are opaque by default. `translucent` blurs what is behind dialogs, drawers, menus, popovers, selects, tooltips and toasts. - - - -### Reduced motion - -`system` follows `prefers-reduced-motion`. `"true"` collapses the duration tokens, which stops transitions and any animation timed by a token. - -```tsx - -``` - -## Nesting - -A nested `ThemePreview` inherits every key it does not set. - - - -A scope with its own `light` or `dark` appearance paints its background. One that only changes accent, gray, radius or scaling is transparent. `hasBackground` overrides either. - - - -### Portals - -Theme values travel through React context, so every portalling component re-emits them onto its popup. A menu opened inside a dark scope is dark, with nothing to configure. - - - -### isRoot - -One theme per document owns the page colour scheme: the scrollbar, the overscroll area and native widget defaults. The first theme with no ancestor claims it. An embedded widget that has no ancestor theme but does not own the page must pass `isRoot={false}`. - -```tsx - - - -``` - -### render - -Merge the theme onto your own element instead of adding a wrapper: - -```tsx -}> - - -``` - -## Controlled - -`defaultValue` seeds a key; `value` controls it. A controlled key always wins, is never persisted and is never written by the inline script. Control is per key. - - - -```tsx -// Appearance from a cookie; accent stays adjustable and persisted - - - -``` - -## Persistence - -Off unless `persistKey` is set. A theme without one keeps its settings in memory, so scopes and embedded widgets never collide. `persist` narrows which keys the namespace stores. - -```tsx -// Everything under one namespace - - -// Only the appearance - -``` - -Themes sharing a `persistKey` stay in step, across tabs as well. Writes merge into the stored object, so themes with different `persist` lists can share one key. - -## Server rendering - -Settings are props, so the server renders them. An inline script, the theme element's first child, patches in what the server cannot know before first paint: stored values, and the OS answer for a `system` appearance. A pinned appearance with no `persistKey` ships no script. Pass `nonce` if your CSP needs one. - -```tsx -// Next.js App Router: app/layout.tsx -import { ThemePreview } from "@raystack/apsara"; - -export default function RootLayout({ children }) { - return ( - - - {children} - - - ); -} -``` - -Nothing is written to ``, so it needs no `suppressHydrationWarning`. - -## useThemePreview - -```tsx -import { useThemePreview } from "@raystack/apsara"; - -function AppearanceToggle() { - const { resolved, setValue } = useThemePreview(); - const isDark = resolved.appearance === "dark"; - - return ( - - ); -} -``` - - - -`value` is what was set, `system` and `auto` included; `resolved` is what is on screen. `root` is the same handle bound to the root provider, so a control inside a scope can change the page: - -```tsx -const { root } = useThemePreview(); -root.setValue({ appearance: "dark" }); -``` - -The hook throws outside a provider. - -### ThemePreviewSwitcher - -An icon button that flips between light and dark. It follows `resolved.appearance`. - - - - - -## Per-component radius - -Components accept a `radius` prop with the theme's five values. It affects only that component and does not compound with the theme radius. - - - -Available on `Button`, `IconButton`, `Badge`, `Callout`, `Chip`, `Input`, `TextArea`, `Image`, `Avatar`, and on the portalled parts `Dialog.Content`, `AlertDialog.Content`, `Drawer.Content`, `Popover.Content`, `Menu.Content`, `ContextMenu.Content`, `Select.Content`, `Combobox.Content`, `Tooltip.Content`, `PreviewCard.Content`, `Command.DialogContent` and `Tour.Content`. It goes on the portalled part, not the root: ``. - -## Customization - -### Tokens - -Every `--rs-*` declaration is wrapped in `:where()` and every theme element carries the `rs-theme` class, so one class selector overrides any token without `!important`: - -```css -.rs-theme { - --rs-color-background-accent-emphasis: #6d28d9; - --rs-radius-3: 10px; -} - -.marketing-page .rs-theme { - --rs-font-title: "Playfair Display", serif; -} -``` - -Inline `style` works too: - -```tsx - -``` - -### Fonts - -Three CSS variables, no prop: - -| Token | Role | -|---|---| -| `--rs-font-body` | Body text | -| `--rs-font-title` | Headings | -| `--rs-font-mono` | Monospace | - -```css -.rs-theme { - --rs-font-body: "Geist", system-ui, sans-serif; - --rs-font-title: "Geist", system-ui, sans-serif; -} -``` - -Import one stylesheet: `@raystack/apsara/style.css` includes the font imports, `@raystack/apsara/style-no-fonts.css` leaves them out for self-hosted fonts. The type scale is tuned for Inter, so another font may need its line heights and tracking adjusted. - -## Migrating from Theme - -Migrate a whole application at once; do not nest the two. - -| Removed | Replacement | -|---|---| -| `theme` | `value.appearance` | -| `defaultTheme` | `defaultValue.appearance` | -| `forcedTheme` | `value.appearance` | -| `accentColor`, `grayColor` as flat props | `defaultValue.accentColor`, `defaultValue.grayColor` | -| `style` | `radius` plus the `--rs-font-*` tokens | -| `onThemeChange` | `onValueChange` | -| `enableSystem` | `appearance: "system"` | -| `enableColorScheme` | Handled by the stylesheet | -| `themes`, `attribute`, `value` as a name-to-attribute map | None. Arbitrary named themes are not supported | -| `ThemeProvider` alias | `ThemePreview` | -| `useTheme().theme` / `.setTheme` / `.resolvedTheme` / `.systemTheme` | `value` / `setValue` / `resolved` / `systemAppearance` | -| `useTheme().themes` / `.forcedTheme` / `.style` / `.scopes` | None | -| `useTheme({ storageKey })` | `useThemePreview().root` | -| `storageKey` | `persistKey`, which also gates persistence | -| Persistence at the root by default | `persistKey` is required to persist | -| `ThemeSwitcher` | `ThemePreviewSwitcher` | - -### Before and after - -```tsx -// Before - track(resolved)} -> - - - -// After - { - if (changed.appearance) track(value.appearance); - }} -> - - -``` - -```tsx -// Before: force dark for a subtree - - - - -// After - - - -``` - -```tsx -// Before: flip the page theme from inside a scope -const { setTheme } = useTheme({ storageKey: "theme" }); - -// After -const { root } = useThemePreview(); -root.setValue({ appearance: "dark" }); -``` - -### Also - -- `style="modern" | "traditional"` becomes a radius level plus a font pair: `defaultValue={{ radius: "large" }}` with `--rs-font-title` and `--rs-font-body` set in CSS. -- `Image` and `Avatar` use the shared five radius values. `Image` gains `large`. `Avatar` has no default radius and follows the theme; pass `radius="full"` to keep circles. -- Tokens are no longer on ``. Consumer CSS and hand-rolled portals outside the provider must move inside it. -- `useThemePreview` throws outside a provider instead of returning a no-op. -- `persistKey` ignores the bare theme name `Theme` stored, so a migrated user starts from the seed once. Use a new key if that matters. - -## API Reference - -### ThemePreview - - - -### ThemeSettings - - diff --git a/apps/www/src/content/docs/theme/preview/props.ts b/apps/www/src/content/docs/theme/preview/props.ts deleted file mode 100644 index 57a7514e6..000000000 --- a/apps/www/src/content/docs/theme/preview/props.ts +++ /dev/null @@ -1,144 +0,0 @@ -export type Appearance = 'light' | 'dark'; -export type AppearanceSetting = 'light' | 'dark' | 'system'; -export type AccentColor = 'indigo' | 'orange' | 'mint'; -export type GrayColorSetting = 'gray' | 'mauve' | 'slate' | 'sage' | 'auto'; -export type Radius = 'none' | 'small' | 'medium' | 'large' | 'full'; -export type Scaling = '0.9' | '0.95' | '1' | '1.05' | '1.1'; -export type PanelBackground = 'solid' | 'translucent'; -export type ReducedMotion = 'true' | 'false' | 'system'; - -/** The theme settings. Every key is independent. */ -export type ThemeSettings = { - /** - * Colour scheme. `system` resolves against `prefers-color-scheme`. - * @defaultValue "system" - */ - appearance: AppearanceSetting; - - /** - * Accent ramp. - * @defaultValue "indigo" - */ - accentColor: AccentColor; - - /** - * Gray ramp. `auto` pairs a complementary gray to the accent. - * @defaultValue "auto" - */ - grayColor: GrayColorSetting; - - /** - * Corner radius, applied as a factor over a fixed base scale. - * @defaultValue "medium" - */ - radius: Radius; - - /** - * Zoom. Multiplies spacing, radius, type and line height together. - * @defaultValue "1" - */ - scaling: Scaling; - - /** - * Whether overlay surfaces are opaque or translucent. - * @defaultValue "solid" - */ - panelBackground: PanelBackground; - - /** - * Motion preference. A forced value collapses the duration tokens. - * @defaultValue "system" - */ - reducedMotion: ReducedMotion; -}; - -export type ThemeSettingKey = keyof ThemeSettings; - -export type ThemePreviewProps = { - /** Seeds uncontrolled keys. A stored user choice overrides it. */ - defaultValue?: Partial; - - /** - * Controlled keys, per key. A controlled key always wins and is never - * persisted. - */ - value?: Partial; - - /** - * Fires when `setValue` requests a change. Controlled keys are reported but - * not applied; changes arriving from storage do not fire it. - */ - onValueChange?: ( - value: ThemeSettings, - changed: Partial - ) => void; - - /** - * Which settings this namespace covers. - * @defaultValue all seven keys - */ - persist?: ThemeSettingKey[]; - - /** Storage namespace. Persistence is off unless this is set. */ - persistKey?: string; - - /** - * Whether this theme owns the document's colour scheme. An embedded widget - * with no ancestor theme should pass `false`. - * @defaultValue true when there is no ancestor theme - */ - isRoot?: boolean; - - /** - * Overrides the painting heuristic: true at the root or for a nested theme - * with its own `light` or `dark` appearance, false otherwise. - */ - hasBackground?: boolean; - - /** - * Suppresses the colour transition during an appearance switch. - * @defaultValue false - */ - disableTransitionOnChange?: boolean; - - /** CSP nonce for the inline script. */ - nonce?: string; - - /** `asChild`-style escape hatch: merges the theme onto your own element. */ - render?: React.ReactElement | ((props: object) => React.ReactElement); - - /** Extra classes. `rs-theme` is always present alongside them. */ - className?: string; - - children?: React.ReactNode; -}; - -/** The theme, as read and driven from anywhere inside a provider. */ -export type ThemeHandle = { - /** Settings as set, `system` and `auto` included. */ - value: ThemeSettings; - /** Settings as applied, with `system` and `auto` resolved. */ - resolved: ThemeSettings & { appearance: Appearance }; - /** Partial settings. Controlled keys are reported, not applied. */ - setValue: (next: Partial) => void; - /** What the OS reports, whatever the current setting is. */ - systemAppearance: Appearance; -}; - -export type UseThemePreviewReturn = ThemeHandle & { - /** The same handle bound to the root provider. */ - root: ThemeHandle; -}; - -export type ThemePreviewSwitcherProps = { - /** - * Square size of the button box, in pixels. - * @defaultValue 30 - */ - size?: number; - /** - * Whether to flip the root theme rather than the nearest scope. - * @defaultValue "nearest" - */ - target?: 'nearest' | 'root'; -}; From 3e10f482f9c14b48f4148b9ee18ebc2670556d00 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Fri, 18 Sep 2026 11:57:45 +0530 Subject: [PATCH 11/22] docs(theme): soften the panel backdrop and widen the playground 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. --- apps/www/src/components/theme-panel-demo.tsx | 69 ++++++++++++++++++- .../src/content/docs/theme/overview/demo.ts | 2 +- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/apps/www/src/components/theme-panel-demo.tsx b/apps/www/src/components/theme-panel-demo.tsx index 25790ba36..d1421b86b 100644 --- a/apps/www/src/components/theme-panel-demo.tsx +++ b/apps/www/src/components/theme-panel-demo.tsx @@ -7,8 +7,11 @@ import { Callout, Checkbox, Chip, + Dialog, + Drawer, Flex, Input, + Menu, Popover, Progress, Select, @@ -107,14 +110,14 @@ function Sampler() { direction='column' align='start' style={{ - minHeight: 168, + minHeight: 188, padding: 'var(--rs-space-4)', borderRadius: 'var(--rs-radius-3)', background: - 'repeating-linear-gradient(45deg, var(--rs-color-background-accent-emphasis) 0 120px, var(--rs-color-background-attention-emphasis) 120px 240px)' + 'radial-gradient(70% 90% at 10% 10%, var(--rs-color-background-accent-emphasis), transparent 60%), radial-gradient(65% 85% at 95% 20%, var(--rs-color-background-danger-emphasis), transparent 60%), radial-gradient(90% 90% at 55% 110%, var(--rs-color-background-attention-emphasis), transparent 65%), var(--rs-color-background-accent-emphasis-hover)' }} > - + Tooltip} @@ -133,6 +136,66 @@ function Sampler() { + + + Menu} /> + + + Actions + Assign member + Rename + + + Delete + + + + + + + Dialog} + /> + + + Dialog + + + + The scrim behind is what a translucent panel reads against. + + + + + + + Drawer} + /> + + + Drawer + + Radius, scaling and panel background all reach it. + + + + + Portalled parts re-emit the theme, so the drawer matches the + scope its trigger lives in. + + + + diff --git a/apps/www/src/content/docs/theme/overview/demo.ts b/apps/www/src/content/docs/theme/overview/demo.ts index 3bd4537de..9ea3ed4b6 100644 --- a/apps/www/src/content/docs/theme/overview/demo.ts +++ b/apps/www/src/content/docs/theme/overview/demo.ts @@ -100,7 +100,7 @@ export const panelBackgroundDemo = { padding: "var(--rs-space-7)", borderRadius: "var(--rs-radius-4)", background: - "repeating-linear-gradient(45deg, var(--rs-color-background-accent-emphasis) 0 120px, var(--rs-color-background-attention-emphasis) 120px 240px)" + "radial-gradient(70% 90% at 10% 10%, var(--rs-color-background-accent-emphasis), transparent 60%), radial-gradient(65% 85% at 95% 20%, var(--rs-color-background-danger-emphasis), transparent 60%), radial-gradient(90% 90% at 55% 110%, var(--rs-color-background-attention-emphasis), transparent 65%), var(--rs-color-background-accent-emphasis-hover)" }} > {["solid", "translucent"].map(panelBackground => ( From da96abe93d80fe7b5cd2e72f4954710367afc478 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Fri, 18 Sep 2026 12:05:39 +0530 Subject: [PATCH 12/22] docs(theme): drop the decorative backdrops from the panel demos 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. --- apps/www/src/components/theme-panel-demo.tsx | 164 ++++++++---------- .../src/content/docs/theme/overview/demo.ts | 38 ++-- 2 files changed, 83 insertions(+), 119 deletions(-) diff --git a/apps/www/src/components/theme-panel-demo.tsx b/apps/www/src/components/theme-panel-demo.tsx index d1421b86b..c7cd0ef46 100644 --- a/apps/www/src/components/theme-panel-demo.tsx +++ b/apps/www/src/components/theme-panel-demo.tsx @@ -104,99 +104,81 @@ function Sampler() { Callouts follow the accent and the radius factor. - {/* The page behind these popups is the colour the translucent panel mixes - from, so without something else under them `Panel` has no visible effect. */} - - - - Tooltip} - /> - Portalled, and still themed - - - - Popover} - /> - + + + Tooltip} + /> + Portalled, and still themed + + + + Popover} + /> + + + Theme values cross the portal through context, so this popup + matches the scope it was opened from. + + + + + + Menu} /> + + + Actions + Assign member + Rename + + + Delete + + + + + + + Dialog} /> + + + Dialog + + + + The scrim behind is what a translucent panel reads against. + + + + + + + Drawer} /> + + + Drawer + + Radius, scaling and panel background all reach it. + + + - Theme values cross the portal through context, so this popup - matches the scope it was opened from. + Portalled parts re-emit the theme, so the drawer matches the + scope its trigger lives in. - - - - - Menu} /> - - - Actions - Assign member - Rename - - - Delete - - - - - - - Dialog} - /> - - - Dialog - - - - The scrim behind is what a translucent panel reads against. - - - - - - - Drawer} - /> - - - Drawer - - Radius, scaling and panel background all reach it. - - - - - Portalled parts re-emit the theme, so the drawer matches the - scope its trigger lives in. - - - - - + + + ); diff --git a/apps/www/src/content/docs/theme/overview/demo.ts b/apps/www/src/content/docs/theme/overview/demo.ts index 9ea3ed4b6..614951406 100644 --- a/apps/www/src/content/docs/theme/overview/demo.ts +++ b/apps/www/src/content/docs/theme/overview/demo.ts @@ -91,45 +91,27 @@ export const scalingDemo = { export const panelBackgroundDemo = { type: 'code', code: ` - + {["solid", "translucent"].map(panelBackground => ( - {/* The same two tokens every overlay surface uses */} - - {panelBackground} + - Open popover} /> + {panelBackground}} /> - The popup uses the same surface. + The popup paints --rs-color-panel. + + Open the popup: it covers this paragraph. On solid it hides the + words behind it, on translucent it blurs them and lets them tint + the surface. + ))} From 69b5179c985a42adf08407d6fb862d8cb7ac527b Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Fri, 18 Sep 2026 12:10:26 +0530 Subject: [PATCH 13/22] fix(theme): soften the panel blur to 40px 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. --- packages/raystack/styles/theme.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/raystack/styles/theme.css b/packages/raystack/styles/theme.css index 2aa652d2f..90477d9a1 100644 --- a/packages/raystack/styles/theme.css +++ b/packages/raystack/styles/theme.css @@ -119,7 +119,7 @@ :where([data-panel-background="translucent"]) { --rs-color-panel: var(--rs-color-panel-translucent); - --rs-panel-backdrop-filter: blur(64px); + --rs-panel-backdrop-filter: blur(40px); } /* Reduced motion: see the end of `effects.css`. */ From 719fdf45b846fff13aa17819580ba6cdf7c29902 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Fri, 18 Sep 2026 12:40:23 +0530 Subject: [PATCH 14/22] fix(theme): land a whole appearance switch on one clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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. --- apps/www/src/components/theme.tsx | 1 - .../breadcrumb/breadcrumb.module.css | 2 +- .../raystack/components/chat/chat.module.css | 5 +- .../components/checkbox/checkbox.module.css | 8 +- .../raystack/components/link/link.module.css | 6 +- .../components/radio/radio.module.css | 8 +- .../__tests__/theme-preview.test.tsx | 142 ++++++++++++++++++ .../components/theme-preview/settings.ts | 6 + .../theme-preview/theme-preview.tsx | 116 ++++++++++---- .../components/toggle/toggle.module.css | 10 +- .../raystack/styles/primitives/appearance.css | 8 +- packages/raystack/styles/theme.css | 38 +++++ 12 files changed, 299 insertions(+), 51 deletions(-) diff --git a/apps/www/src/components/theme.tsx b/apps/www/src/components/theme.tsx index 863a21740..719ec9f3a 100644 --- a/apps/www/src/components/theme.tsx +++ b/apps/www/src/components/theme.tsx @@ -15,7 +15,6 @@ export function ThemeProvider({ children }: { children: ReactNode }) { {children} diff --git a/packages/raystack/components/breadcrumb/breadcrumb.module.css b/packages/raystack/components/breadcrumb/breadcrumb.module.css index 0d9c2742f..15cfe75b3 100644 --- a/packages/raystack/components/breadcrumb/breadcrumb.module.css +++ b/packages/raystack/components/breadcrumb/breadcrumb.module.css @@ -41,7 +41,7 @@ @media (prefers-reduced-motion: no-preference) { .breadcrumb-link { - transition: color var(--rs-duration-fast) var(--rs-ease-out); + transition: color var(--rs-duration-normal) var(--rs-ease-out); } } diff --git a/packages/raystack/components/chat/chat.module.css b/packages/raystack/components/chat/chat.module.css index 195dcce99..fea7f8157 100644 --- a/packages/raystack/components/chat/chat.module.css +++ b/packages/raystack/components/chat/chat.module.css @@ -82,9 +82,8 @@ @media (prefers-reduced-motion: no-preference) { .jump-button { transition: - background-color var(--rs-duration-fast) var(--rs-ease-out), - opacity var(--rs-duration-fast) var(--rs-ease-out), - transform var(--rs-duration-fast) var(--rs-ease-out); + var(--rs-transition-interactive), + transform var(--rs-duration-normal) var(--rs-ease-out); } .jump-button:active { diff --git a/packages/raystack/components/checkbox/checkbox.module.css b/packages/raystack/components/checkbox/checkbox.module.css index 5362606b2..f049f810b 100644 --- a/packages/raystack/components/checkbox/checkbox.module.css +++ b/packages/raystack/components/checkbox/checkbox.module.css @@ -21,8 +21,8 @@ cursor: pointer; flex-shrink: 0; transition: - background-color var(--rs-duration-fast) var(--rs-ease-out), - border-color var(--rs-duration-fast) var(--rs-ease-out); + background-color var(--rs-duration-normal) var(--rs-ease-out), + border-color var(--rs-duration-normal) var(--rs-ease-out); } /* Size variants */ @@ -57,8 +57,8 @@ @media (prefers-reduced-motion: no-preference) { .checkbox { transition: - background-color var(--rs-duration-fast) var(--rs-ease-out), - border-color var(--rs-duration-fast) var(--rs-ease-out), + background-color var(--rs-duration-normal) var(--rs-ease-out), + border-color var(--rs-duration-normal) var(--rs-ease-out), transform var(--rs-duration-press) var(--rs-ease-out); } } diff --git a/packages/raystack/components/link/link.module.css b/packages/raystack/components/link/link.module.css index 8c8deeb43..1dfd70bb4 100644 --- a/packages/raystack/components/link/link.module.css +++ b/packages/raystack/components/link/link.module.css @@ -10,12 +10,12 @@ @media (prefers-reduced-motion: no-preference) { .link { transition: - text-decoration-color var(--rs-duration-fast) var(--rs-ease-out), - opacity var(--rs-duration-fast) var(--rs-ease-out); + text-decoration-color var(--rs-duration-normal) var(--rs-ease-out), + opacity var(--rs-duration-normal) var(--rs-ease-out); } /* Press dims at the snappy press tier; releasing eases back at the base - (fast) tier above: quick press, gentle release. */ + tier above: quick press, gentle release. */ .link:active { transition: opacity var(--rs-duration-press) var(--rs-ease-out); } diff --git a/packages/raystack/components/radio/radio.module.css b/packages/raystack/components/radio/radio.module.css index fed4f7277..9b2a104c0 100644 --- a/packages/raystack/components/radio/radio.module.css +++ b/packages/raystack/components/radio/radio.module.css @@ -21,8 +21,8 @@ cursor: pointer; box-sizing: border-box; transition: - background-color var(--rs-duration-fast) var(--rs-ease-out), - border-color var(--rs-duration-fast) var(--rs-ease-out); + background-color var(--rs-duration-normal) var(--rs-ease-out), + border-color var(--rs-duration-normal) var(--rs-ease-out); } /* Group-level size, low specificity via :where() so item-level size wins */ @@ -59,8 +59,8 @@ @media (prefers-reduced-motion: no-preference) { .radioitem { transition: - background-color var(--rs-duration-fast) var(--rs-ease-out), - border-color var(--rs-duration-fast) var(--rs-ease-out), + background-color var(--rs-duration-normal) var(--rs-ease-out), + border-color var(--rs-duration-normal) var(--rs-ease-out), transform var(--rs-duration-press) var(--rs-ease-out); } } diff --git a/packages/raystack/components/theme-preview/__tests__/theme-preview.test.tsx b/packages/raystack/components/theme-preview/__tests__/theme-preview.test.tsx index f66bda0c5..e2fed39e1 100644 --- a/packages/raystack/components/theme-preview/__tests__/theme-preview.test.tsx +++ b/packages/raystack/components/theme-preview/__tests__/theme-preview.test.tsx @@ -863,6 +863,148 @@ describe('disableTransitionOnChange', () => { }); }); +describe('appearance transition', () => { + // The attribute lives on the document, not on the theme element. + const marker = 'data-rs-appearance-change'; + beforeEach(() => { + document.documentElement.removeAttribute(marker); + }); + + function Switcher({ to }: { to: 'light' | 'dark' }) { + const { setValue } = useThemePreview(); + return ( + + ); + } + + function stubViewTransition() { + let resolveFinished: () => void = () => undefined; + const finished = new Promise(resolve => { + resolveFinished = resolve; + }); + const startViewTransition = vi.fn((update: () => void) => { + update(); + return { finished }; + }); + Object.defineProperty(document, 'startViewTransition', { + configurable: true, + writable: true, + value: startViewTransition + }); + return { + startViewTransition, + finish: async () => { + resolveFinished(); + await act(async () => { + await finished; + await Promise.resolve(); + }); + }, + restore: () => { + Reflect.deleteProperty(document, 'startViewTransition'); + } + }; + } + + it('crossfades an appearance switch and marks the document while it runs', async () => { + const user = userEvent.setup(); + const vt = stubViewTransition(); + + render( + + + + ); + expect(document.documentElement).not.toHaveAttribute(marker); + + await act(async () => { + await user.click(screen.getByRole('button')); + }); + expect(vt.startViewTransition).toHaveBeenCalledTimes(1); + expect(document.documentElement).toHaveAttribute(marker); + + await vt.finish(); + expect(document.documentElement).not.toHaveAttribute(marker); + vt.restore(); + }); + + it('applies the change even where view transitions are unsupported', async () => { + const user = userEvent.setup(); + const { container } = render( + + + + ); + + await act(async () => { + await user.click(screen.getByRole('button')); + }); + expect(themeElement(container)).toHaveAttribute('data-theme', 'dark'); + expect(document.documentElement).not.toHaveAttribute(marker); + }); + + it('leaves a setting other than appearance alone', async () => { + const user = userEvent.setup(); + const vt = stubViewTransition(); + + function RadiusSwitcher() { + const { setValue } = useThemePreview(); + return ( + + ); + } + + const { container } = render( + + + + ); + await act(async () => { + await user.click(screen.getByRole('button')); + }); + expect(vt.startViewTransition).not.toHaveBeenCalled(); + expect(themeElement(container)).toHaveAttribute('data-radius', 'full'); + vt.restore(); + }); + + it('skips the crossfade when transitions are disabled', async () => { + const user = userEvent.setup(); + const vt = stubViewTransition(); + + render( + + + + ); + await act(async () => { + await user.click(screen.getByRole('button')); + }); + expect(vt.startViewTransition).not.toHaveBeenCalled(); + expect(document.documentElement).not.toHaveAttribute(marker); + vt.restore(); + }); + + it('skips the crossfade under reduced motion', async () => { + const user = userEvent.setup(); + const vt = stubViewTransition(); + + render( + + + + ); + await act(async () => { + await user.click(screen.getByRole('button')); + }); + expect(vt.startViewTransition).not.toHaveBeenCalled(); + vt.restore(); + }); +}); + // ─── Mount reconciliation ─────────────────────────────────────────────────── describe('mount reconciliation', () => { diff --git a/packages/raystack/components/theme-preview/settings.ts b/packages/raystack/components/theme-preview/settings.ts index 7638af8b2..98724fd9d 100644 --- a/packages/raystack/components/theme-preview/settings.ts +++ b/packages/raystack/components/theme-preview/settings.ts @@ -97,6 +97,12 @@ export const ROOT_ATTRIBUTE = 'data-rs-root'; /** Stable override class for consumer stylesheets. */ export const THEME_CLASS = 'rs-theme'; +/** + * Set on `` for the length of an appearance switch, so the view-transition + * rules in `theme.css` apply to that crossfade and not to an app's own. + */ +export const APPEARANCE_CHANGE_ATTRIBUTE = 'data-rs-appearance-change'; + /** Bump when the stored shape changes. */ export const STORAGE_VERSION = 1; diff --git a/packages/raystack/components/theme-preview/theme-preview.tsx b/packages/raystack/components/theme-preview/theme-preview.tsx index b191c6c73..220b30005 100644 --- a/packages/raystack/components/theme-preview/theme-preview.tsx +++ b/packages/raystack/components/theme-preview/theme-preview.tsx @@ -18,6 +18,7 @@ import { useState, useSyncExternalStore } from 'react'; +import { flushSync } from 'react-dom'; import { RootThemeContext, @@ -28,6 +29,7 @@ import { } from './context'; import { createThemeScript, THEME_ID_ATTRIBUTE } from './script'; import { + APPEARANCE_CHANGE_ATTRIBUTE, assignSetting, ROOT_ATTRIBUTE, resolveSettings, @@ -74,7 +76,7 @@ export interface ThemePreviewProps isRoot?: boolean; /** Overrides the paint heuristic: root or own light/dark appearance paints. */ hasBackground?: boolean; - /** Suppresses the colour transition during an appearance switch. */ + /** Switches appearance with no crossfade, and no component transitions either. */ disableTransitionOnChange?: boolean; /** CSP nonce for the inline script. */ nonce?: string; @@ -268,8 +270,12 @@ export function ThemePreview({ persistedKeysRef.current = persistedKeys; const settingsRef = useRef(settings); settingsRef.current = settings; + const resolvedRef = useRef(resolved); + resolvedRef.current = resolved; const onValueChangeRef = useRef(onValueChange); onValueChangeRef.current = onValueChange; + const disableTransitionRef = useRef(disableTransitionOnChange); + disableTransitionRef.current = disableTransitionOnChange; const setValue = useCallback( (next: Partial) => { @@ -290,27 +296,36 @@ export function ThemePreview({ } if (isSettingsEmpty(changed)) return; - if (!isSettingsEmpty(patch)) { - const persisted = persistedKeysRef.current; - // A refused write falls back to memory rather than dropping the change. - const stored = - persistKey && persisted.length > 0 - ? writeStoredSettings(persistKey, persisted, patch) - : false; - const inMemory: Partial = {}; - for (const key of THEME_SETTING_KEYS) { - const pending = patch[key]; - if (pending === undefined) continue; - if (stored && persisted.includes(key)) continue; - assignSetting(inMemory, key, pending); + const apply = () => { + if (!isSettingsEmpty(patch)) { + const persisted = persistedKeysRef.current; + // A refused write falls back to memory rather than dropping the change. + const stored = + persistKey && persisted.length > 0 + ? writeStoredSettings(persistKey, persisted, patch) + : false; + const inMemory: Partial = {}; + for (const key of THEME_SETTING_KEYS) { + const pending = patch[key]; + if (pending === undefined) continue; + if (stored && persisted.includes(key)) continue; + assignSetting(inMemory, key, pending); + } + if (!isSettingsEmpty(inMemory)) { + setLocal(previous => ({ ...previous, ...inMemory })); + } } - if (!isSettingsEmpty(inMemory)) { - setLocal(previous => ({ ...previous, ...inMemory })); - } - } - // From the request, not settled state, so hydration never fires it. - onValueChangeRef.current?.({ ...current, ...changed }, changed); + // From the request, not settled state, so hydration never fires it. + onValueChangeRef.current?.({ ...current, ...changed }, changed); + }; + + // An appearance swap repaints the page; anything else is a local change. + if (changed.appearance === undefined || disableTransitionRef.current) { + apply(); + return; + } + crossfadeAppearance(apply, resolvedRef.current.reducedMotion); }, [persistKey] ); @@ -339,7 +354,7 @@ export function ThemePreview({ (isRootTheme || (ownAppearance !== undefined && ownAppearance !== 'system')); - useAppearanceTransitionGuard( + useAppearanceTransition( resolved.appearance, disableTransitionOnChange, nonce @@ -427,17 +442,66 @@ export function ThemePreview({ ThemePreview.displayName = 'ThemePreview'; -// Suppresses the colour transition while an appearance switch lands. -function useAppearanceTransitionGuard( +type ViewTransitionDocument = Document & { + startViewTransition?: (update: () => void) => { finished: Promise }; +}; + +function prefersReducedMotion(setting: string): boolean { + if (setting === 'true') return true; + if (setting === 'false') return false; + return ( + window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false + ); +} + +/** + * A theme switch repaints far more than the elements that own a transition, so + * left alone a page arrives in waves — controls at 200ms, everything else on the + * next frame. A view transition crossfades one snapshot of the page instead, so + * all of it lands on the same clock; `theme.css` gives that crossfade the same + * duration and easing the components use. + * + * The attribute scopes those rules to this transition, and `flushSync` puts the + * new attributes in the DOM while the snapshot is still being captured. + */ +function crossfadeAppearance(apply: () => void, reducedMotion: string): void { + if (typeof document === 'undefined') { + apply(); + return; + } + const doc = document as ViewTransitionDocument; + if (!doc.startViewTransition || prefersReducedMotion(reducedMotion)) { + apply(); + return; + } + + const root = document.documentElement; + root.setAttribute(APPEARANCE_CHANGE_ATTRIBUTE, ''); + const transition = doc.startViewTransition(() => { + flushSync(apply); + }); + // A skipped transition rejects; the attribute still has to come off. + transition.finished + .catch(() => undefined) + .finally(() => root.removeAttribute(APPEARANCE_CHANGE_ATTRIBUTE)); +} + +/** + * Covers the changes `setValue` never sees — the OS flipping under `system`, + * another tab writing storage — and suppresses motion entirely when that is what + * was asked for. Those arrive mid-render, too late to capture a snapshot from, + * so they take the components' own transitions rather than a crossfade. + */ +function useAppearanceTransition( appearance: string, - enabled: boolean, + disabled: boolean, nonce: string | undefined ): void { const previous = useRef(null); useEffect(() => { const last = previous.current; previous.current = appearance; - if (!enabled || last === null || last === appearance) return; + if (!disabled || last === null || last === appearance) return; if (typeof document === 'undefined') return; const style = document.createElement('style'); @@ -455,5 +519,5 @@ function useAppearanceTransitionGuard( window.clearTimeout(timer); style.remove(); }; - }, [appearance, enabled, nonce]); + }, [appearance, disabled, nonce]); } diff --git a/packages/raystack/components/toggle/toggle.module.css b/packages/raystack/components/toggle/toggle.module.css index 9a737aff2..67459e86b 100644 --- a/packages/raystack/components/toggle/toggle.module.css +++ b/packages/raystack/components/toggle/toggle.module.css @@ -27,17 +27,15 @@ @media (prefers-reduced-motion: no-preference) { .toggleContent { transition: - background-color var(--rs-duration-fast) var(--rs-ease-out), - box-shadow var(--rs-duration-fast) var(--rs-ease-out); + background-color var(--rs-duration-normal) var(--rs-ease-out), + box-shadow var(--rs-duration-normal) var(--rs-ease-out); } .group .toggle { - /* color entry matches .toggle's own transition (line 9) so both are - driven by the same token */ transition: color var(--rs-duration-normal) var(--rs-ease-out), - background-color var(--rs-duration-fast) var(--rs-ease-out), - box-shadow var(--rs-duration-fast) var(--rs-ease-out); + background-color var(--rs-duration-normal) var(--rs-ease-out), + box-shadow var(--rs-duration-normal) var(--rs-ease-out); } } diff --git a/packages/raystack/styles/primitives/appearance.css b/packages/raystack/styles/primitives/appearance.css index 17c8222a7..5721f859c 100644 --- a/packages/raystack/styles/primitives/appearance.css +++ b/packages/raystack/styles/primitives/appearance.css @@ -5,11 +5,13 @@ */ /* Smooth theme switch transition. Zero specificity like every other theme rule, - so a component's own `transition` shorthand always wins over it. */ + so a component's own `transition` shorthand always wins over it. Same duration + and easing as --rs-transition-interactive, so the ground a control sits on + never arrives at a different time than the control. */ :where([data-theme="light"], [data-theme="dark"]) { transition: - background-color var(--rs-duration-slow) ease, - color var(--rs-duration-slow) ease; + background-color var(--rs-duration-normal) var(--rs-ease-out), + color var(--rs-duration-normal) var(--rs-ease-out); } /* Native UI (form controls, scrollbars, text selection) follows the scoped theme */ diff --git a/packages/raystack/styles/theme.css b/packages/raystack/styles/theme.css index 90477d9a1..2a0dffcc0 100644 --- a/packages/raystack/styles/theme.css +++ b/packages/raystack/styles/theme.css @@ -122,4 +122,42 @@ --rs-panel-backdrop-filter: blur(40px); } +/* Appearance switch. Components transition their own colours at + --rs-duration-normal, but most of a page — text, borders, the ground itself — + carries no transition and lands a frame after the flip while the rest is still + fading. Crossfading one snapshot of the page puts all of it on the same clock. + Giving every element the transition instead means ~10k concurrent transitions + on a page this size, which renders slower than no animation at all. + + Scoped to the attribute `ThemePreview` sets for the length of the switch, so + an app's own view transitions keep their timing. */ +html[data-rs-appearance-change]::view-transition-old(root), +html[data-rs-appearance-change]::view-transition-new(root) { + animation-duration: var(--rs-duration-normal); + animation-timing-function: var(--rs-ease-out); + /* The UA crossfade blends `plus-lighter`, which suits two frames of the same + brightness; across a light/dark swap it blows out to white at the midpoint. */ + mix-blend-mode: normal; +} + +html[data-rs-appearance-change]::view-transition-old(root) { + animation-name: rs-appearance-out; +} + +html[data-rs-appearance-change]::view-transition-new(root) { + animation-name: rs-appearance-in; +} + +@keyframes rs-appearance-out { + to { + opacity: 0; + } +} + +@keyframes rs-appearance-in { + from { + opacity: 0; + } +} + /* Reduced motion: see the end of `effects.css`. */ From 89b7ac8b1fdce2ccc99d456d4b40e6645aa9252c Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Fri, 18 Sep 2026 14:00:34 +0530 Subject: [PATCH 15/22] fix(theme): restore the scrim behind portalled overlays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ``, which portals inherit. Moving the site onto `ThemePreview` put the tokens on an element inside ``, and everything portalled past it lost them. A Base UI portal renders its own `
` under `` — `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. --- .../alert-dialog/alert-dialog-content.tsx | 2 +- .../components/combobox/combobox-content.tsx | 2 +- .../components/command/command-dialog.tsx | 2 +- .../context-menu/context-menu-content.tsx | 2 +- .../components/dialog/dialog-content.tsx | 2 +- .../components/drawer/drawer-content.tsx | 2 +- .../raystack/components/menu/menu-content.tsx | 2 +- .../raystack/components/popover/popover.tsx | 2 +- .../components/preview-card/preview-card.tsx | 2 +- .../components/select/select-content.tsx | 2 +- .../__tests__/theme-preview.test.tsx | 50 +++++++++++++++++++ .../components/theme-preview/portal.ts | 6 +++ .../components/toast/toast-provider.tsx | 2 +- .../components/tooltip/tooltip-content.tsx | 2 +- .../raystack/components/tour/tour-content.tsx | 2 +- 15 files changed, 69 insertions(+), 13 deletions(-) diff --git a/packages/raystack/components/alert-dialog/alert-dialog-content.tsx b/packages/raystack/components/alert-dialog/alert-dialog-content.tsx index 708216a94..cdaa185f5 100644 --- a/packages/raystack/components/alert-dialog/alert-dialog-content.tsx +++ b/packages/raystack/components/alert-dialog/alert-dialog-content.tsx @@ -28,7 +28,7 @@ export const AlertDialogContent = ({ }: AlertDialogContentProps) => { const theme = useThemeInjection(); return ( - + + + + + + + + + + { expect(portalled).not.toHaveClass('rs-theme'); expect(portalled).not.toHaveAttribute('data-theme'); }); + + /* The backdrop is a sibling of the popup, not a descendant, so a theme on the + popup alone never reaches it and `--rs-color-overlay` resolves to nothing — + an invisible scrim. The portal node is the only ancestor they share. */ + it('themes the parts that sit beside the popup, not just the popup', async () => { + const user = userEvent.setup(); + render( + + + open} /> + + Titled + + + + ); + await user.click(screen.getByRole('button', { name: 'open' })); + + const backdrop = document.querySelector('[data-slot="dialog-backdrop"]'); + expect(backdrop).not.toBeNull(); + expect(backdrop?.closest('[data-theme]')).toHaveAttribute( + 'data-theme', + 'dark' + ); + }); + + it('gives each open portal its own scope', async () => { + const user = userEvent.setup(); + render( + + + + open} /> + + Titled + + + + + ); + await user.click(screen.getByRole('button', { name: 'open' })); + + // The trigger's scope wins over the page it is portalled past. + const backdrop = document.querySelector('[data-slot="dialog-backdrop"]'); + expect(backdrop?.closest('[data-theme]')).toHaveAttribute( + 'data-theme', + 'light' + ); + }); }); // ─── Per-component radius ─────────────────────────────────────────────────── diff --git a/packages/raystack/components/theme-preview/portal.ts b/packages/raystack/components/theme-preview/portal.ts index cf3264b96..1759a1684 100644 --- a/packages/raystack/components/theme-preview/portal.ts +++ b/packages/raystack/components/theme-preview/portal.ts @@ -14,6 +14,12 @@ export interface ThemeInjectionProps { * Re-emits the theme onto a portalled element; `undefined` outside a provider. * Spread first, then pass `className` yourself: * `` + * + * Spread it on the `Portal` as well as the popup. A Base UI portal renders its + * own `
` under ``, one per instance, so the theme reaches the parts + * that are siblings of the popup rather than inside it — a dialog's backdrop + * draws `--rs-color-overlay`, which is only declared under `[data-theme]`, and + * without this resolves to nothing and leaves the scrim invisible. */ export function useThemeInjection(): ThemeInjectionProps | undefined { const theme = useThemeContextOrNull(); diff --git a/packages/raystack/components/toast/toast-provider.tsx b/packages/raystack/components/toast/toast-provider.tsx index 57db385aa..060939bbb 100644 --- a/packages/raystack/components/toast/toast-provider.tsx +++ b/packages/raystack/components/toast/toast-provider.tsx @@ -50,7 +50,7 @@ export function ToastProvider({ return ( {children} - + + - + Date: Sat, 19 Sep 2026 02:04:46 +0530 Subject: [PATCH 16/22] fix(docs): run react-live demos that declare a helper in noInline mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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`. --- .../src/components/demo/demo-playground.tsx | 8 +- apps/www/src/components/demo/demo-preview.tsx | 8 +- apps/www/src/components/demo/no-inline.ts | 13 + packages/raystack/CHANGELOG.md | 40 - .../__tests__/data-slots.test.tsx | 77 -- .../theme-provider/__tests__/theme.test.tsx | 977 ------------------ .../components/theme-provider/index.tsx | 3 - .../components/theme-provider/switcher.tsx | 37 - .../components/theme-provider/theme.tsx | 607 ----------- .../components/theme-provider/types.ts | 114 -- .../__tests__/data-slots.test.tsx | 0 .../__tests__/mocks.ts | 0 .../__tests__/script.test.ts | 0 .../__tests__/ssr.test.tsx | 0 .../__tests__/store.test.ts | 0 .../__tests__/theme.test.tsx} | 0 .../{theme-preview => theme}/context.ts | 0 .../{theme-preview => theme}/index.tsx | 0 .../{theme-preview => theme}/portal.ts | 0 .../{theme-preview => theme}/script.ts | 0 .../{theme-preview => theme}/settings.ts | 0 .../{theme-preview => theme}/store.ts | 0 .../{theme-preview => theme}/switcher.tsx | 0 .../theme-preview.tsx => theme/theme.tsx} | 0 .../use-system-appearance.ts | 0 packages/raystack/styles/fonts-legacy.css | 3 - packages/raystack/styles/radius.css | 21 - 27 files changed, 27 insertions(+), 1881 deletions(-) create mode 100644 apps/www/src/components/demo/no-inline.ts delete mode 100644 packages/raystack/components/theme-provider/__tests__/data-slots.test.tsx delete mode 100644 packages/raystack/components/theme-provider/__tests__/theme.test.tsx delete mode 100644 packages/raystack/components/theme-provider/index.tsx delete mode 100644 packages/raystack/components/theme-provider/switcher.tsx delete mode 100644 packages/raystack/components/theme-provider/theme.tsx delete mode 100644 packages/raystack/components/theme-provider/types.ts rename packages/raystack/components/{theme-preview => theme}/__tests__/data-slots.test.tsx (100%) rename packages/raystack/components/{theme-preview => theme}/__tests__/mocks.ts (100%) rename packages/raystack/components/{theme-preview => theme}/__tests__/script.test.ts (100%) rename packages/raystack/components/{theme-preview => theme}/__tests__/ssr.test.tsx (100%) rename packages/raystack/components/{theme-preview => theme}/__tests__/store.test.ts (100%) rename packages/raystack/components/{theme-preview/__tests__/theme-preview.test.tsx => theme/__tests__/theme.test.tsx} (100%) rename packages/raystack/components/{theme-preview => theme}/context.ts (100%) rename packages/raystack/components/{theme-preview => theme}/index.tsx (100%) rename packages/raystack/components/{theme-preview => theme}/portal.ts (100%) rename packages/raystack/components/{theme-preview => theme}/script.ts (100%) rename packages/raystack/components/{theme-preview => theme}/settings.ts (100%) rename packages/raystack/components/{theme-preview => theme}/store.ts (100%) rename packages/raystack/components/{theme-preview => theme}/switcher.tsx (100%) rename packages/raystack/components/{theme-preview/theme-preview.tsx => theme/theme.tsx} (100%) rename packages/raystack/components/{theme-preview => theme}/use-system-appearance.ts (100%) delete mode 100644 packages/raystack/styles/fonts-legacy.css delete mode 100644 packages/raystack/styles/radius.css diff --git a/apps/www/src/components/demo/demo-playground.tsx b/apps/www/src/components/demo/demo-playground.tsx index 7e1231403..ae4b8314d 100644 --- a/apps/www/src/components/demo/demo-playground.tsx +++ b/apps/www/src/components/demo/demo-playground.tsx @@ -16,6 +16,7 @@ import { useDemoContext } from './demo-context'; import DemoControls from './demo-controls'; import DemoPreview from './demo-preview'; import DemoTitle from './demo-title'; +import { needsNoInline } from './no-inline'; import styles from './styles.module.css'; import { ComponentPropsType, @@ -124,7 +125,12 @@ export default function DemoPlayground({ - +
+
{tabs && tabs.length > 1 && (
diff --git a/apps/www/src/components/demo/no-inline.ts b/apps/www/src/components/demo/no-inline.ts new file mode 100644 index 000000000..a08e802d8 --- /dev/null +++ b/apps/www/src/components/demo/no-inline.ts @@ -0,0 +1,13 @@ +/** + * Whether a demo has to run in react-live's `noInline` mode. + * + * Without it, react-live parses the snippet as one expression, so a demo that + * declares a helper component before the JSX fails to parse. `noInline` runs + * the snippet as a function body instead, where the result is handed over by + * calling `render(...)`. The two modes are mutually exclusive: a bare + * expression renders nothing under `noInline`, so this keys off the `render(` + * call the mode requires. + */ +export function needsNoInline(code: string | undefined): boolean { + return /(^|[^.\w])render\s*\(/.test(code ?? ''); +} diff --git a/packages/raystack/CHANGELOG.md b/packages/raystack/CHANGELOG.md index 299f53f06..594a2dd4e 100644 --- a/packages/raystack/CHANGELOG.md +++ b/packages/raystack/CHANGELOG.md @@ -70,46 +70,6 @@ and [Icons](https://apsara.raystack.io/docs/theme/icons). `IconComponent`, `IconProps`, `IconProviderProps`, and `IconProvider` itself. -### Theme — element-mounted `ThemePreview` (RFC 004) - -`ThemePreview` is the next `Theme`, shipping alongside the existing one so -applications migrate at their own pace. It mounts every token-bearing -attribute on a real element instead of ``, so the root theme, a nested -scope and a portal re-injection are the same component: server-renderable, -more than one provider per page, and scoped themes that reach into portals. -See [ThemePreview](https://apsara.raystack.io/docs/theme/preview), which -includes the migration guide. - -#### New features - -- **`ThemePreview`** with seven independently seedable, controllable and - persistable settings: `appearance`, `accentColor`, `grayColor`, `radius`, - `scaling`, `panelBackground` and `reducedMotion`. Persistence is gated on - `persistKey`. A small pre-hydration script patches stored values, and a - `system` appearance, before first paint. -- **Token layer rebuilt.** 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. New `--rs-scaling`, a radius factor over a fixed - base scale replacing the two `data-style` scales, and panel, overlay and - backdrop-filter tokens. -- **Per-component `radius`** on Button, IconButton, Badge, Callout, Chip, - Input, TextArea, Image, Avatar and the portalled `Content` sub-components. -- **Round controls follow the theme radius.** `Switch` and the `Slider` track - and thumb are round from `medium` up and square at `none` and `small`, via - a new `--rs-radius-thumb` token. `Radio` stays round. -- **`style-no-fonts.css`** is published alongside `style.css` for consumers - that self-host or already load the fonts. - -#### Changes for existing `Theme` users - -- **`:where()` changes who wins a tie.** Token declarations no longer carry - specificity, so a consumer rule on `:root` that used to lose to - `[data-theme="dark"]` now wins. Scope such overrides to `.rs-theme` or a - theme attribute if you relied on the old order. -- **`Avatar` has no default `radius`** and follows the theme radius like every - other component. Pass `radius` to pin a value. - ## 0.49.0 ### Calendar / DatePicker / RangePicker improvements (PR #819) diff --git a/packages/raystack/components/theme-provider/__tests__/data-slots.test.tsx b/packages/raystack/components/theme-provider/__tests__/data-slots.test.tsx deleted file mode 100644 index 20b9527fd..000000000 --- a/packages/raystack/components/theme-provider/__tests__/data-slots.test.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { render } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { expectSlots, getSlot } from '~/test-utils/data-slots'; -import { ThemeSwitcher } from '../switcher'; -import { Theme } from '../theme'; - -// jsdom doesn't ship these; the root Theme needs them on mount. -const localStorageMock = { - getItem: vi.fn(), - setItem: vi.fn(), - removeItem: vi.fn(), - clear: vi.fn() -}; - -Object.defineProperty(window, 'localStorage', { value: localStorageMock }); - -Object.defineProperty(window, 'matchMedia', { - writable: true, - value: vi.fn().mockImplementation(query => ({ - matches: false, - media: query, - onchange: null, - addListener: vi.fn(), - removeListener: vi.fn(), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - dispatchEvent: vi.fn() - })) -}); - -beforeEach(() => { - localStorageMock.getItem.mockReset(); -}); - -describe('Theme data-slot contract', () => { - it('exposes the script slot at the root', () => { - const { container } = render( - -
child
-
- ); - expect(getSlot(container, 'theme-script')?.tagName).toBe('SCRIPT'); - }); - - it('exposes the scope slot for a nested scope with overrides', () => { - const { container } = render( - - - -
nested
-
-
-
- ); - expectSlots(container, ['theme-scope']); - }); - - it('renders no wrapper for a nested scope with no overrides', () => { - const { container } = render( - - -
child
-
-
- ); - expect(getSlot(container, 'theme-scope')).toBeNull(); - }); - - it('exposes the switcher slot', () => { - const { container } = render( - - - - ); - expect(getSlot(container, 'theme-switcher')).not.toBeNull(); - }); -}); diff --git a/packages/raystack/components/theme-provider/__tests__/theme.test.tsx b/packages/raystack/components/theme-provider/__tests__/theme.test.tsx deleted file mode 100644 index acac6bcd2..000000000 --- a/packages/raystack/components/theme-provider/__tests__/theme.test.tsx +++ /dev/null @@ -1,977 +0,0 @@ -import { - act, - fireEvent, - render, - screen, - waitFor -} from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { useState } from 'react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { XIcon } from '~/icons'; -import { ThemeSwitcher } from '../switcher'; -import { Theme, useTheme } from '../theme'; - -// jsdom doesn't ship these; the root Theme needs them on mount. -const localStorageMock = { - getItem: vi.fn(), - setItem: vi.fn(), - removeItem: vi.fn(), - clear: vi.fn() -}; - -Object.defineProperty(window, 'localStorage', { - value: localStorageMock -}); - -Object.defineProperty(window, 'matchMedia', { - writable: true, - value: vi.fn().mockImplementation(query => ({ - matches: false, - media: query, - onchange: null, - addListener: vi.fn(), - removeListener: vi.fn(), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - dispatchEvent: vi.fn() - })) -}); - -beforeEach(() => { - // mockReset clears implementations too, which is vital for isolation since tests - // set `mockReturnValue`/`mockImplementation` and mockClear alone would let - // those leak into subsequent tests. - localStorageMock.getItem.mockReset(); - localStorageMock.setItem.mockReset(); - localStorageMock.removeItem.mockReset(); - document.documentElement.removeAttribute('data-theme'); - document.documentElement.removeAttribute('data-style'); - document.documentElement.removeAttribute('data-accent-color'); - document.documentElement.removeAttribute('data-gray-color'); -}); - -afterEach(() => { - vi.clearAllMocks(); -}); - -// ─── Root mode ────────────────────────────────────────────────────────────── - -describe('Theme (root)', () => { - describe('Basic Rendering', () => { - it('renders children correctly', () => { - render( - -
Test content
-
- ); - - expect(screen.getByText('Test content')).toBeInTheDocument(); - }); - - it('provides default theme context', () => { - const TestComponent = () => { - const { theme, themes } = useTheme(); - return ( -
- {theme} - {themes.join(',')} -
- ); - }; - - render( - - - - ); - - expect(screen.getByTestId('current-theme')).toHaveTextContent('system'); - expect(screen.getByTestId('available-themes')).toHaveTextContent( - 'light,dark,system' - ); - }); - - it('applies default theme attributes to document', () => { - render( - -
Test
-
- ); - - expect(document.documentElement.getAttribute('data-style')).toBe( - 'modern' - ); - expect(document.documentElement.getAttribute('data-accent-color')).toBe( - 'indigo' - ); - expect(document.documentElement.getAttribute('data-gray-color')).toBe( - 'gray' - ); - }); - }); - - describe('Configuration', () => { - it('accepts custom default theme', () => { - const TestComponent = () => { - const { theme } = useTheme(); - return {theme}; - }; - - render( - - - - ); - - expect(screen.getByTestId('theme')).toHaveTextContent('dark'); - }); - - it('applies custom style attributes', () => { - render( - -
Test
-
- ); - - expect(document.documentElement.getAttribute('data-style')).toBe( - 'traditional' - ); - expect(document.documentElement.getAttribute('data-accent-color')).toBe( - 'mint' - ); - expect(document.documentElement.getAttribute('data-gray-color')).toBe( - 'slate' - ); - }); - - it('handles forced theme', () => { - const TestComponent = () => { - const { theme, forcedTheme } = useTheme(); - return ( -
- {theme} - {forcedTheme} -
- ); - }; - - render( - - - - ); - - expect(screen.getByTestId('theme')).toHaveTextContent('system'); - expect(screen.getByTestId('forced')).toHaveTextContent('dark'); - }); - }); - - describe('useTheme', () => { - it('provides theme context values', () => { - const TestComponent = () => { - const context = useTheme(); - return ( -
- - {typeof context.setTheme === 'function' ? 'true' : 'false'} - - - {Array.isArray(context.themes) ? 'true' : 'false'} - -
- ); - }; - - render( - - - - ); - - expect(screen.getByTestId('has-set-theme')).toHaveTextContent('true'); - expect(screen.getByTestId('has-themes')).toHaveTextContent('true'); - }); - - it('allows theme changes', () => { - const TestComponent = () => { - const { setTheme } = useTheme(); - return ; - }; - - render( - - - - ); - - fireEvent.click(screen.getByText('Set Dark Theme')); - - expect(localStorageMock.setItem).toHaveBeenCalledWith('theme', 'dark'); - }); - - it('returns default context when used outside any provider', () => { - const TestComponent = () => { - const { setTheme, themes } = useTheme(); - return ( -
- - {typeof setTheme === 'function' ? 'true' : 'false'} - - {themes.length} -
- ); - }; - - render(); - - expect(screen.getByTestId('has-set-theme')).toHaveTextContent('true'); - expect(screen.getByTestId('themes-length')).toHaveTextContent('0'); - }); - }); - - describe('resolvedTheme', () => { - it('reflects forcedTheme when set', () => { - const Probe = () => { - const { resolvedTheme } = useTheme(); - return {resolvedTheme}; - }; - - render( - - - - ); - - expect(screen.getByTestId('resolved')).toHaveTextContent('dark'); - }); - }); - - describe('onThemeChange', () => { - it('does not fire on initial mount', async () => { - const handler = vi.fn(); - - render( - -
- - ); - - // Settle any post-mount effects (media-query listener + re-render). - await waitFor(() => { - expect(document.documentElement.getAttribute('data-theme')).toBe( - 'light' - ); - }); - - expect(handler).not.toHaveBeenCalled(); - }); - - it('fires when setTheme changes the theme', () => { - const handler = vi.fn(); - const Probe = () => { - const { setTheme } = useTheme(); - return ; - }; - - render( - - - - ); - - fireEvent.click(screen.getByText('set')); - expect(handler).toHaveBeenCalledWith('dark', 'dark'); - }); - - it('does not over-fire when the consumer passes an inline callback', () => { - const handler = vi.fn(); - const Tree = () => { - const [count, setCount] = useState(0); - return ( - handler(t, r)} - > - - - ); - }; - - render(); - fireEvent.click(screen.getByText(/bump/)); - fireEvent.click(screen.getByText(/bump/)); - - // Theme never changed; consumer re-renders shouldn't drive the callback. - expect(handler).not.toHaveBeenCalled(); - }); - }); - - describe('System Theme Detection', () => { - it('enables system theme by default', () => { - const TestComponent = () => { - const { themes, systemTheme } = useTheme(); - return ( -
- - {themes.includes('system') ? 'true' : 'false'} - - {systemTheme || 'none'} -
- ); - }; - - render( - - - - ); - - expect(screen.getByTestId('has-system')).toHaveTextContent('true'); - }); - - it('can disable system theme', () => { - const TestComponent = () => { - const { themes } = useTheme(); - return {themes.join(',')}; - }; - - render( - - - - ); - - expect(screen.getByTestId('themes')).toHaveTextContent('light,dark'); - }); - }); - - describe('Local Storage Integration', () => { - it('reads initial theme from localStorage', () => { - localStorageMock.getItem.mockReturnValue('dark'); - - const TestComponent = () => { - const { theme } = useTheme(); - return {theme}; - }; - - render( - - - - ); - - expect(localStorageMock.getItem).toHaveBeenCalledWith('theme'); - }); - - it('uses custom storage key', () => { - const TestComponent = () => { - const { setTheme } = useTheme(); - return ; - }; - - render( - - - - ); - - fireEvent.click(screen.getByText('Set Theme')); - - expect(localStorageMock.setItem).toHaveBeenCalledWith( - 'custom-theme', - 'light' - ); - }); - }); -}); - -// ─── Scoped mode (stateless) ──────────────────────────────────────────────── - -describe('Theme (scoped)', () => { - it('renders a div wrapper with children when nested with overrides', () => { - render( - - - inside - - - ); - - const child = screen.getByTestId('child'); - expect(child.parentElement?.tagName).toBe('DIV'); - expect(screen.getByText('inside')).toBeInTheDocument(); - }); - - it('writes data-theme on the scoped wrapper', () => { - render( - - - - - - ); - - expect(screen.getByTestId('child').parentElement).toHaveAttribute( - 'data-theme', - 'dark' - ); - }); - - it('passes children through unchanged when no override props are provided', () => { - // No-op nesting: a nested `` with no override props should not - // introduce a wrapper element. This preserves the pre-PR behavior for - // consumers who accidentally nest providers. - const { container } = render( - - - - - - ); - - // The child's parent is the test container, with no scope wrapper in between. - expect(screen.getByTestId('child').parentElement).toBe(container); - }); - - it('writes every supported data attribute', () => { - render( - - - - - - ); - - const wrapper = screen.getByTestId('child').parentElement!; - expect(wrapper).toHaveAttribute('data-theme', 'light'); - expect(wrapper).toHaveAttribute('data-accent-color', 'orange'); - expect(wrapper).toHaveAttribute('data-gray-color', 'mauve'); - expect(wrapper).toHaveAttribute('data-style', 'traditional'); - }); - - it('does not propagate scope attrs to the document root', () => { - render( - - - - - - ); - - // Root provider drives 's attrs; scope only changes its own wrapper. - expect(document.documentElement.getAttribute('data-theme')).toBe('light'); - expect(document.documentElement.getAttribute('data-accent-color')).toBe( - 'indigo' - ); - expect(screen.getByTestId('child').parentElement).toHaveAttribute( - 'data-theme', - 'dark' - ); - }); -}); - -// `useTheme()` always targets the nearest scope. Every active scope (with -// overrides or a storageKey) owns its own theme state; persistence is a -// separate concern (only persistent scopes write to localStorage). -describe('useTheme inside a stateless scope', () => { - it('owns its own theme state but inherits non-overridden fields from parent', () => { - const Probe = () => { - const { theme, resolvedTheme, accentColor, grayColor } = useTheme(); - return ( -
- {theme ?? ''} - {resolvedTheme} - {accentColor} - {grayColor} -
- ); - }; - - render( - - - - - - ); - - // Scope owns its theme state; no defaultTheme passed → starts undefined. - expect(screen.getByTestId('theme')).toHaveTextContent(''); - // resolvedTheme reflects what's displayed for this subtree (forcedTheme wins). - expect(screen.getByTestId('resolved')).toHaveTextContent('light'); - // Overridden field comes from the scope. - expect(screen.getByTestId('accent')).toHaveTextContent('orange'); - // Non-overridden field inherits from the root. - expect(screen.getByTestId('gray')).toHaveTextContent('gray'); - }); - - it('setTheme inside a stateless scope updates the scope, not the root', () => { - const Probe = () => { - const { theme, setTheme } = useTheme(); - return ( - <> - - {theme ?? ''} - - ); - }; - - render( - - - - - - ); - - fireEvent.click(screen.getByText('set')); - // Scope owns its own state, so the call updates the scope, root's storage is - // untouched. - expect(localStorageMock.setItem).not.toHaveBeenCalledWith('theme', 'dark'); - expect(screen.getByTestId('theme')).toHaveTextContent('dark'); - }); - - it('useTheme({ storageKey }) reaches past the nearest scope to a specific ancestor', () => { - const Probe = () => { - const { setTheme } = useTheme({ storageKey: 'theme' }); - return ; - }; - - render( - - - - - - ); - - fireEvent.click(screen.getByText('set root')); - // Hook targeted the root by its storageKey, so root's storage was written. - expect(localStorageMock.setItem).toHaveBeenCalledWith('theme', 'dark'); - }); -}); - -// `Theme` is the canonical export; `ThemeProvider` is a back-compat alias. -describe('Theme alias compatibility', () => { - it('exports Theme and ThemeProvider as the same value', async () => { - const { Theme: T, ThemeProvider: TP } = await import('../theme'); - expect(TP).toBe(T); - }); -}); - -// ─── Scoped mode (persistent) ─────────────────────────────────────────────── - -// Persistent scope: `storageKey` on a nested `` enables localStorage- -// backed state. Descendants read and write the scope's theme via `useTheme()`, -// which returns layered state (scope's theme/setTheme, parent's other fields). -describe('Theme (persistent scope)', () => { - it('reads the initial scope theme from localStorage', () => { - localStorageMock.getItem.mockImplementation((k: string) => - k === 'scope-1' ? 'dark' : null - ); - - render( - - - - - - ); - - expect(screen.getByTestId('child').parentElement).toHaveAttribute( - 'data-theme', - 'dark' - ); - }); - - it('falls back to defaultTheme when storage is empty', () => { - localStorageMock.getItem.mockReturnValue(null); - - render( - - - - - - ); - - expect(screen.getByTestId('child').parentElement).toHaveAttribute( - 'data-theme', - 'dark' - ); - }); - - it('inherits data-theme from parent when storage and defaultTheme are both empty', () => { - localStorageMock.getItem.mockReturnValue(null); - - render( - - - - - - ); - - // Scope has no own value, so the wrapper mirrors the parent's - // resolvedTheme. This lets CSS rules like - // `[data-accent-color='X'][data-theme='dark']` still match when only one - // attribute is overridden in the scope. - expect(screen.getByTestId('child').parentElement).toHaveAttribute( - 'data-theme', - 'light' - ); - }); - - it('forcedTheme wins over stored value (and is not persisted)', () => { - localStorageMock.getItem.mockImplementation((k: string) => - k === 'scope-4' ? 'dark' : null - ); - - render( - - - - - - ); - - // Displayed = forcedTheme; storage untouched. - expect(screen.getByTestId('child').parentElement).toHaveAttribute( - 'data-theme', - 'light' - ); - expect(localStorageMock.setItem).not.toHaveBeenCalledWith( - 'scope-4', - 'light' - ); - }); - - it('useTheme inside a persistent scope returns the scope theme and setter', () => { - localStorageMock.getItem.mockImplementation((k: string) => - k === 'scope-5' ? 'dark' : null - ); - - const Probe = () => { - const { theme, setTheme } = useTheme(); - return ( -
- {theme ?? 'undefined'} - -
- ); - }; - - render( - - - - - - ); - - expect(screen.getByTestId('theme')).toHaveTextContent('dark'); - - fireEvent.click(screen.getByText('set light')); - - expect(screen.getByTestId('theme')).toHaveTextContent('light'); - // setTheme writes to the scope's key, not the root's. - expect(localStorageMock.setItem).toHaveBeenCalledWith('scope-5', 'light'); - expect(localStorageMock.setItem).not.toHaveBeenCalledWith('theme', 'light'); - }); - - it('clearing via setTheme(undefined) removes the storage entry', () => { - localStorageMock.getItem.mockImplementation((k: string) => - k === 'scope-6' ? 'dark' : null - ); - - const Probe = () => { - const { setTheme } = useTheme(); - return ; - }; - - render( - - - - - - - ); - - fireEvent.click(screen.getByText('clear')); - - expect(localStorageMock.removeItem).toHaveBeenCalledWith('scope-6'); - // After clearing, the scope has no own value and inherits the parent's - // resolvedTheme on the wrapper. - expect(screen.getByTestId('child').parentElement).toHaveAttribute( - 'data-theme', - 'light' - ); - }); - - it('syncs across tabs via the storage event', () => { - localStorageMock.getItem.mockImplementation((k: string) => - k === 'scope-7' ? 'dark' : null - ); - - render( - - - - - - ); - - expect(screen.getByTestId('child').parentElement).toHaveAttribute( - 'data-theme', - 'dark' - ); - - // Another tab updated the same key. - act(() => { - window.dispatchEvent( - new StorageEvent('storage', { - key: 'scope-7', - newValue: 'light', - oldValue: 'dark' - }) - ); - }); - - expect(screen.getByTestId('child').parentElement).toHaveAttribute( - 'data-theme', - 'light' - ); - }); - - it('nested persistent scopes each manage their own key', () => { - localStorageMock.getItem.mockImplementation((k: string) => { - if (k === 'scope-outer') return 'dark'; - if (k === 'scope-inner') return 'light'; - return null; - }); - - render( - - - - - - - - ); - - const innerWrapper = screen.getByTestId('inner').parentElement!; - expect(innerWrapper).toHaveAttribute('data-theme', 'light'); - // Outer wrapper is the grandparent. - expect(innerWrapper.parentElement).toHaveAttribute('data-theme', 'dark'); - }); -}); - -// ─── ThemeSwitcher ────────────────────────────────────────────────────────── - -describe('ThemeSwitcher', () => { - describe('Basic Rendering', () => { - it('renders theme switcher', () => { - render( - - - - ); - - const icon = document.querySelector('svg'); - expect(icon).toBeInTheDocument(); - }); - - it('shows moon icon for light theme', () => { - render( - - - - ); - - const icon = document.querySelector('svg'); - expect(icon).toBeInTheDocument(); - }); - - it('shows sun icon for dark theme', () => { - render( - - - - ); - - const icon = document.querySelector('svg'); - expect(icon).toBeInTheDocument(); - }); - - it('applies custom size to the button box', () => { - render( - - - - ); - - // size drives the button box; the icon fills it via IconButton's CSS. - const button = document.querySelector('button'); - expect(button).toHaveStyle({ width: '40px', height: '40px' }); - }); - }); - - describe('Theme Switching', () => { - it('switches from dark to light', () => { - const TestComponent = () => { - const { theme } = useTheme(); - return ( -
- {theme} - -
- ); - }; - - render( - - - - ); - - expect(screen.getByTestId('current-theme')).toHaveTextContent('dark'); - - const icon = document.querySelector('svg'); - fireEvent.click(icon!); - - expect(localStorageMock.setItem).toHaveBeenCalledWith('theme', 'light'); - }); - }); - - describe('Accessibility', () => { - it('renders a button with an accessible name', () => { - render( - - - - ); - - expect( - screen.getByRole('button', { name: /switch to dark theme/i }) - ).toBeInTheDocument(); - }); - - it('toggles the theme with Enter and Space', async () => { - const user = userEvent.setup(); - - render( - - - - ); - - const button = screen.getByRole('button', { name: /theme/i }); - button.focus(); - - await user.keyboard('{Enter}'); - expect(localStorageMock.setItem).toHaveBeenLastCalledWith( - 'theme', - 'dark' - ); - - await user.keyboard(' '); - expect(localStorageMock.setItem).toHaveBeenLastCalledWith( - 'theme', - 'light' - ); - }); - }); - - // The registry itself is tested in `icons/__tests__/registry.test.tsx`. These - // cover the wiring: that `` mounts the IconProvider, and only when the - // consumer configures it. - describe('icons', () => { - const StubIcon = (props: React.SVGProps) => ( - - ); - - it('resolves the overrides given to Theme', () => { - render( - - - - ); - - expect(screen.getByTestId('stub')).toHaveAttribute('data-icon', 'XIcon'); - }); - - it('resolves the icon props given to Theme', () => { - render( - - - - ); - - expect(document.querySelector('[data-icon="XIcon"]')).toHaveAttribute( - 'stroke-width', - '1.5' - ); - }); - - it('renders the defaults when Theme configures no icons', () => { - render( - - - - ); - - const icon = document.querySelector('[data-icon="XIcon"]'); - expect(icon).toBeInTheDocument(); - expect(icon).toHaveAttribute('stroke-width', '1.5'); - }); - - it('layers a nested Theme per icon key', () => { - render( - - - - - - ); - - // The inner Theme sets props only, so XIcon keeps the outer override and - // gains the inner stroke weight. - expect(screen.getByTestId('stub')).toHaveAttribute('stroke-width', '1'); - }); - - it('lets a nested Theme replace an icon the outer one named', () => { - const Inner = (props: React.SVGProps) => ( - - ); - - render( - - - - - - ); - - expect(screen.getByTestId('inner')).toBeInTheDocument(); - expect(screen.queryByTestId('stub')).not.toBeInTheDocument(); - }); - }); -}); diff --git a/packages/raystack/components/theme-provider/index.tsx b/packages/raystack/components/theme-provider/index.tsx deleted file mode 100644 index 1421d368f..000000000 --- a/packages/raystack/components/theme-provider/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export { ThemeSwitcher } from './switcher'; -export { Theme, ThemeProvider, useTheme } from './theme'; -export { ThemeProviderProps } from './types'; diff --git a/packages/raystack/components/theme-provider/switcher.tsx b/packages/raystack/components/theme-provider/switcher.tsx deleted file mode 100644 index 65d098989..000000000 --- a/packages/raystack/components/theme-provider/switcher.tsx +++ /dev/null @@ -1,37 +0,0 @@ -'use client'; - -import { MoonIcon, SunIcon } from '~/icons'; -import { IconButton } from '../icon-button'; -import { useTheme } from './theme'; - -enum Theme { - DARK = 'dark', - LIGHT = 'light' -} - -type Props = { size?: number }; - -export function ThemeSwitcher({ size = 30, ...props }: Props) { - const { theme, setTheme } = useTheme(); - const isDark = theme === Theme.DARK; - - const onClickHandler = () => { - setTheme(isDark ? Theme.LIGHT : Theme.DARK); - }; - - return ( - - {/* size drives the button box; IconButton's CSS sizes the icon to fill - the padded content area, so the icons don't set their own dimensions. */} - {isDark ? : } - - ); -} - -ThemeSwitcher.displayName = 'ThemeSwitcher'; diff --git a/packages/raystack/components/theme-provider/theme.tsx b/packages/raystack/components/theme-provider/theme.tsx deleted file mode 100644 index f8af0486b..000000000 --- a/packages/raystack/components/theme-provider/theme.tsx +++ /dev/null @@ -1,607 +0,0 @@ -'use client'; - -import { - createContext, - memo, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState -} from 'react'; -import { IconProvider } from '~/icons/create-icon'; -import type { - ScopeRef, - ThemeProviderProps, - UseThemeOptions, - UseThemeProps -} from './types'; -import { COLOR_SCHEMES } from './types'; - -const colorSchemes: readonly string[] = COLOR_SCHEMES; -const MEDIA = '(prefers-color-scheme: dark)'; -const isServer = typeof window === 'undefined'; -const ThemeContext = createContext(undefined); -const defaultContext: UseThemeProps = { setTheme: _ => {}, themes: [] }; - -/** - * Read the current theme state from the nearest `` ancestor (default) - * or from a specific ancestor by its `storageKey` when one is provided. - * - * `setTheme` from the return value updates *that* scope only. It never - * propagates outward. To flip the page-level theme from inside a scope, - * pass the root provider's `storageKey` (default `"theme"`). - */ -export const useTheme = (options?: UseThemeOptions): UseThemeProps => { - const ctx = useContext(ThemeContext) ?? defaultContext; - if (options?.storageKey) { - const target = ctx.scopes?.[options.storageKey]; - if (target) { - return { ...ctx, theme: target.theme, setTheme: target.setTheme }; - } - } - return ctx; -}; - -export function Theme({ icons, children, ...props }: ThemeProviderProps) { - const context = useContext(ThemeContext); - - // Mount the icon registry only when the consumer configures it, so a tree - // without icon overrides gains no provider and no extra render work. - // Nesting layers per icon key, matching how `Scoped` layers theme tokens. - const { components, props: iconProps } = icons ?? {}; - const content = - components || iconProps ? ( - - {children} - - ) : ( - children - ); - - // Nested usage: scoped subtree. Render a wrapper element that overrides - // theme tokens locally via `data-*` attributes; the parent provider's - // global state remains the source of truth for descendants reading - // `useTheme()`. - if (context) return {content}; - return {content}; -} - -Theme.displayName = 'Theme'; - -/** - * @deprecated Use `Theme` instead. `ThemeProvider` is kept as an alias for - * backward compatibility and will be removed in a future major release. - */ -export const ThemeProvider = Theme; - -const readScopeStorage = (key: string): string | undefined => { - if (isServer) return undefined; - try { - return localStorage.getItem(key) ?? undefined; - } catch { - return undefined; - } -}; - -const Scoped = ({ - storageKey, - defaultTheme, - forcedTheme, - accentColor, - grayColor, - style, - children -}: ThemeProviderProps) => { - const parent = useContext(ThemeContext); - const isPersistent = !!storageKey; - const hasOverrides = !!( - forcedTheme || - accentColor || - grayColor || - style || - defaultTheme - ); - - // Every active scope owns its theme state so `useTheme()` always targets - // the nearest scope, independent of persistence. Persistent scopes seed - // their state from localStorage on first mount; stateless ones start from - // `defaultTheme` (or undefined) and live only in memory. - const [stored, setStored] = useState(() => - isPersistent - ? (readScopeStorage(storageKey!) ?? defaultTheme) - : defaultTheme - ); - - // Re-sync if the storageKey itself changes mid-life. - useEffect(() => { - if (!isPersistent) return; - setStored(readScopeStorage(storageKey!) ?? defaultTheme); - // defaultTheme is the seed only when storage is empty; intentionally - // excluded from deps to avoid re-applying it on prop changes. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [storageKey, isPersistent]); - - // Persist on change; clear when unset. Compare against the current - // storage value first so initial mounts (and StrictMode double-effects) - // don't write back what we just read or fire spurious storage events to - // other tabs. - useEffect(() => { - if (!isPersistent) return; - try { - const current = localStorage.getItem(storageKey!); - if (stored === undefined) { - if (current !== null) localStorage.removeItem(storageKey!); - } else if (current !== stored) { - localStorage.setItem(storageKey!, stored); - } - } catch { - // unsupported (private mode, quota exceeded) - } - }, [isPersistent, storageKey, stored]); - - // Cross-tab sync. - useEffect(() => { - if (!isPersistent) return; - const onStorage = (e: StorageEvent) => { - if (e.key !== storageKey) return; - setStored(e.newValue ?? undefined); - }; - window.addEventListener('storage', onStorage); - return () => window.removeEventListener('storage', onStorage); - }, [isPersistent, storageKey]); - - // `forcedTheme` wins for display; otherwise the scope's own stored value - // (which falls back to the parent's via `resolvedTheme` below when empty). - const displayed = forcedTheme ?? stored; - - // Layer scope overrides on top of the parent's context so `useTheme()` - // inside the scope sees the effective values. Every active scope (persistent - // or with overrides) owns its own `theme`/`setTheme`, and persistence is - // orthogonal. Scopes with a `storageKey` register themselves into `scopes` - // so `useTheme({ storageKey })` can address them past the nearest one. - const layered = useMemo(() => { - if (!parent) return undefined; - if (!isPersistent && !hasOverrides) return parent; - const ownRef: ScopeRef = { theme: stored, setTheme: setStored }; - const scopes = storageKey - ? { ...parent.scopes, [storageKey]: ownRef } - : parent.scopes; - return { - ...parent, - theme: stored, - setTheme: setStored, - forcedTheme: forcedTheme ?? parent.forcedTheme, - resolvedTheme: displayed ?? parent.resolvedTheme, - style: style ?? parent.style, - accentColor: accentColor ?? parent.accentColor, - grayColor: grayColor ?? parent.grayColor, - scopes - }; - }, [ - parent, - isPersistent, - hasOverrides, - storageKey, - stored, - displayed, - forcedTheme, - style, - accentColor, - grayColor - ]); - - // No-op nesting: a stateless scope with no overrides passes children - // through without a wrapper or new provider. Persistent scopes always - // render the wrapper because descendants rely on the scope's context. - if (!isPersistent && !hasOverrides) return <>{children}; - - // Mirror the layered (own + inherited) values onto the wrapper so CSS rules - // that combine attributes, for example `[data-accent-color='orange'][data-theme='dark']`, - // match even when the consumer overrides only one attribute. - return ( - -
- {children} -
-
- ); -}; - -Scoped.displayName = 'Theme.Scoped'; - -const defaultThemes: string[] = [...COLOR_SCHEMES]; - -const Root = ({ - forcedTheme, - disableTransitionOnChange = false, - enableSystem = true, - enableColorScheme = true, - storageKey = 'theme', - themes = defaultThemes, - defaultTheme = enableSystem ? 'system' : 'light', - attribute = 'data-theme', - value, - children, - nonce, - style = 'modern', - accentColor = 'indigo', - grayColor = 'gray', - onThemeChange -}: ThemeProviderProps) => { - const [theme, setThemeState] = useState(() => - getTheme(storageKey, defaultTheme) - ); - const [resolvedTheme, setResolvedTheme] = useState( - undefined - ); - const attrs = !value ? themes : Object.values(value); - - const applyTheme = useCallback( - (theme: string | undefined) => { - let resolved = theme; - if (!resolved) return; - - // If theme is system, resolve it before setting theme - if (theme === 'system' && enableSystem) { - resolved = getSystemTheme(); - } - - const name = value ? value[resolved] : resolved; - const enable = disableTransitionOnChange ? disableAnimation() : null; - const d = document.documentElement; - - if (attribute === 'class') { - d.classList.remove(...attrs); - - if (name) d.classList.add(name); - } else { - if (name) { - d.setAttribute(attribute, name); - } else { - d.removeAttribute(attribute); - } - } - - d.setAttribute('data-style', style); - d.setAttribute('data-accent-color', accentColor); - d.setAttribute('data-gray-color', grayColor); - - if (enableColorScheme) { - const fallback = colorSchemes.includes(defaultTheme) - ? defaultTheme - : null; - const colorScheme = colorSchemes.includes(resolved) - ? resolved - : fallback; - d.style.colorScheme = colorScheme ?? ''; - } - - enable?.(); - }, - [ - style, - accentColor, - grayColor, - attribute, - attrs, - value, - enableSystem, - enableColorScheme, - defaultTheme - ] - ); - - const setTheme = useCallback( - (theme: string | undefined) => { - // Root has no parent to inherit from, so `undefined` is a no-op here. - // (Persistent scopes use `undefined` to clear and re-inherit.) - if (theme === undefined) return; - setThemeState(theme); - - // Save to storage - try { - localStorage.setItem(storageKey, theme); - } catch (e) { - // Unsupported - } - }, - [storageKey] - ); - - const handleMediaQuery = useCallback( - (e: MediaQueryListEvent | MediaQueryList) => { - const resolved = getSystemTheme(e); - setResolvedTheme(resolved); - - if (theme === 'system' && enableSystem && !forcedTheme) { - applyTheme('system'); - } - }, - [theme, forcedTheme, enableSystem, applyTheme] - ); - - // Always listen to System preference - useEffect(() => { - const media = window.matchMedia(MEDIA); - - media.addEventListener('change', handleMediaQuery); - handleMediaQuery(media); - - return () => media.removeEventListener('change', handleMediaQuery); - }, [handleMediaQuery]); - - // localStorage event handling - useEffect(() => { - const handleStorage = (e: StorageEvent) => { - if (e.key !== storageKey) { - return; - } - - // If default theme set, use it if localstorage === null (happens on local storage manual deletion) - const theme = e.newValue || defaultTheme; - setTheme(theme); - }; - - window.addEventListener('storage', handleStorage); - return () => window.removeEventListener('storage', handleStorage); - }, [setTheme]); - - // Ref-held callback so consumer render churn doesn't drive effect cadence. - const onThemeChangeRef = useRef(onThemeChange); - onThemeChangeRef.current = onThemeChange; - const lastRef = useRef<{ theme: string; resolved: string } | undefined>( - undefined - ); - - // Apply on theme/forcedTheme change, then notify on real changes. - useEffect(() => { - const target = forcedTheme ?? theme; - if (target) applyTheme(target); - - if (!theme) return; - const resolved = - forcedTheme ?? (theme === 'system' ? resolvedTheme : theme); - if (!resolved) return; - - const prev = lastRef.current; - lastRef.current = { theme, resolved }; - - if ( - prev !== undefined && - (prev.theme !== theme || prev.resolved !== resolved) - ) { - onThemeChangeRef.current?.(theme, resolved); - } - }, [forcedTheme, theme, resolvedTheme, applyTheme]); - - const providerValue = useMemo( - () => ({ - theme, - setTheme, - forcedTheme, - resolvedTheme: - forcedTheme ?? (theme === 'system' ? resolvedTheme : theme), - themes: enableSystem ? [...themes, 'system'] : themes, - systemTheme: (enableSystem ? resolvedTheme : undefined) as - | 'light' - | 'dark' - | undefined, - style, - accentColor, - grayColor, - // Register the root in the scopes registry so descendants can target - // it explicitly via `useTheme({ storageKey })`. - scopes: { [storageKey]: { theme, setTheme } satisfies ScopeRef } - }), - [ - theme, - setTheme, - forcedTheme, - resolvedTheme, - enableSystem, - themes, - style, - accentColor, - grayColor, - storageKey - ] - ); - - return ( - - - {children} - - ); -}; - -Root.displayName = 'Theme.Root'; - -const ThemeScript = memo( - ({ - forcedTheme, - storageKey, - attribute, - enableSystem, - enableColorScheme, - defaultTheme, - value, - attrs, - nonce, - style, - accentColor, - grayColor - }: ThemeProviderProps & { attrs: string[]; defaultTheme: string }) => { - const defaultSystem = defaultTheme === 'system'; - - // Code-golfing the amount of characters in the script - const optimization = (() => { - if (attribute === 'class') { - const removeClasses = `c.remove(${attrs - .map((t: string) => `'${t}'`) - .join(',')})`; - - return `var d=document.documentElement,c=d.classList;${removeClasses};`; - } else { - return `var d=document.documentElement,n='${attribute}',s='setAttribute';`; - } - })(); - - const fallbackColorScheme = (() => { - if (!enableColorScheme) { - return ''; - } - - const fallback = colorSchemes.includes(defaultTheme) - ? defaultTheme - : null; - - if (fallback) { - return `if(e==='light'||e==='dark'||!e)d.style.colorScheme=e||'${defaultTheme}'`; - } else { - return `if(e==='light'||e==='dark')d.style.colorScheme=e`; - } - })(); - - const updateDOM = ( - name: string, - literal: boolean = false, - setColorScheme = true - ) => { - const resolvedName = value ? value[name] : name; - const val = literal ? name : `'${resolvedName}'`; - let text = ''; - - // MUCH faster to set colorScheme alongside HTML attribute/class - // as it only incurs 1 style recalculation rather than 2 - // This can save over 250ms of work for pages with big DOM - if ( - enableColorScheme && - setColorScheme && - !literal && - colorSchemes.includes(name) - ) { - text += `d.style.colorScheme = '${name}';`; - } - - if (attribute === 'class') { - if (literal) { - text += `if(${val})c.add(${val})`; - } else if (resolvedName) { - text += `c.add(${val})`; - } else { - text += `null`; - } - } else { - if (literal) { - text += `if(${val})d[s](n,${val})`; - } else if (resolvedName) { - text += `d[s](n,${val})`; - } - } - - return text; - }; - - const scriptSrc = (() => { - if (forcedTheme) { - return `!function(){${optimization}${updateDOM(forcedTheme)};d.setAttribute('data-style','${style}');d.setAttribute('data-accent-color','${accentColor}');d.setAttribute('data-gray-color','${grayColor}');}()`; - } - - if (enableSystem) { - return `!function(){try{${optimization}var e=localStorage.getItem('${storageKey}');if('system'===e||(!e&&${defaultSystem})){var t='${MEDIA}',m=window.matchMedia(t);if(m.media!==t||m.matches){${updateDOM( - 'dark' - )}}else{${updateDOM('light')}}}else if(e){${ - value ? `var x=${JSON.stringify(value)};` : '' - }${updateDOM(value ? `x[e]` : 'e', true)}}${ - !defaultSystem - ? `else{` + updateDOM(defaultTheme, false, false) + '}' - : '' - }${fallbackColorScheme};d.setAttribute('data-style','${style}');d.setAttribute('data-accent-color','${accentColor}');d.setAttribute('data-gray-color','${grayColor}');}catch(e){}}()`; - } - - return `!function(){try{${optimization}var e=localStorage.getItem('${storageKey}');if(e){${ - value ? `var x=${JSON.stringify(value)};` : '' - }${updateDOM(value ? `x[e]` : 'e', true)}}else{${updateDOM( - defaultTheme, - false, - false - )};}${fallbackColorScheme};d.setAttribute('data-style','${style}');d.setAttribute('data-accent-color','${accentColor}');d.setAttribute('data-gray-color','${grayColor}');}catch(t){}}();`; - })(); - - return ( -