diff --git a/.agents/skills/apsara/SKILL.md b/.agents/skills/apsara/SKILL.md index 5ba3b52b7..c0ff86a71 100644 --- a/.agents/skills/apsara/SKILL.md +++ b/.agents/skills/apsara/SKILL.md @@ -1,6 +1,6 @@ --- name: apsara -description: Helps consume the @raystack/apsara React component library correctly in an application. Use when installing or setting up Apsara, building UI with Apsara components (Button, Dialog, Select, Menu, DataTable, Form, Tabs, Sidebar, Toast, etc.), theming (light/dark, accent/gray colors, modern/traditional style), styling with design tokens, or troubleshooting Apsara component behavior. Covers install, the Theme provider, the `--rs-*` token system, compound-component composition, and common pitfalls. +description: Helps consume the @raystack/apsara React component library correctly in an application. Use when installing or setting up Apsara, building UI with Apsara components (Button, Dialog, Select, Menu, DataTable, Form, Tabs, Sidebar, Toast, etc.), theming (light/dark, accent/gray colors, radius, scaling), styling with design tokens, or troubleshooting Apsara component behavior. Covers install, the `Theme` component, the `--rs-*` token system, compound-component composition, and common pitfalls. license: ISC metadata: author: raystack @@ -8,7 +8,7 @@ metadata: # Apsara -Apsara (`@raystack/apsara`) is an open-source React component library built on [Base UI](https://base-ui.com/) primitives. It targets enterprise, data-dense interfaces (data tables, navigation shells, forms, overlays) and ships ~70 accessible, typed components. Styling is **vanilla CSS driven by `--rs-*` design tokens** that react to `data-*` attributes set by the theme provider — there is no Tailwind, no runtime CSS-in-JS, and no per-component install step. +Apsara (`@raystack/apsara`) is an open-source React component library built on [Base UI](https://base-ui.com/) primitives. It targets enterprise, data-dense interfaces (data tables, navigation shells, forms, overlays) and ships ~70 accessible, typed components. Styling is **vanilla CSS driven by `--rs-*` design tokens** that react to `data-*` attributes set by the `Theme` component — there is no Tailwind, no runtime CSS-in-JS, and no per-component install step. ## What this skill is for @@ -16,7 +16,7 @@ Use this skill to help a **consumer** of the published package: - install and wire up Apsara in a new or existing app (Next.js, Vite, etc.) - pick and correctly compose Apsara components for a UI task -- theme the app (light/dark, accent/gray color, modern/traditional style) +- theme the app (light/dark, accent/gray color, radius, scaling, panel background, reduced motion) - style and customize components using design tokens and `data-*` attributes - avoid common composition and SSR pitfalls @@ -36,7 +36,7 @@ When you need a prop you are unsure about, fetch the component's `.mdx` page rat ## Core facts (always true) 1. **One package, one CSS import.** `npm install @raystack/apsara`, then `import "@raystack/apsara/style.css"` once at the app root. That stylesheet contains every component's styles and all tokens. -2. **Wrap the app in ``.** Theming, dark mode, and the no-flash hydration script all come from the `Theme` provider. Without it, `data-theme`/token resolution will not work. +2. **Wrap the app in ``.** Every `--rs-*` token is declared on the element `Theme` renders, so anything outside it has no tokens at all. Dark mode and the no-flash script come from it too. 3. **Components are compound, dot-notation.** Apsara exports a single name per component and hangs sub-parts off it: `Dialog.Content`, `Select.Trigger`, `Menu.Item`, `Tabs.Tab`. It does **not** export flat names like `DialogContent`. (Contrast with shadcn/Radix and coss.) 4. **Style with tokens, never hard-coded values.** Use `--rs-*` custom properties (`var(--rs-color-foreground-base-primary)`, `var(--rs-space-5)`) so styling follows the active theme. 5. **Built on Base UI.** Components expose Base UI `data-*` state attributes (`data-open`, `data-disabled`, `data-starting-style`, …) for state-driven CSS, and trigger-based overlays follow Base UI composition. @@ -62,7 +62,7 @@ When you need a prop you are unsure about, fetch the component's `.mdx` page rat ## References (read on demand) - `references/setup.md` — install, CSS import, `` wiring for Next.js App Router & Vite, icons & hooks subpath exports -- `references/theming.md` — full `Theme` / `ThemeProvider` API, `useTheme`, dark mode, accent/gray/style options, scoped (nested) themes, `ThemeSwitcher` +- `references/theming.md` — full `Theme` API, the seven settings, `useTheme`, dark mode, persistence, scoped (nested) themes, `ThemeSwitcher` - `references/tokens.md` — **complete `--rs-*` token reference**: semantic colors, color scales, spacing, radius, typography, effects, theme `data-*` attributes - `references/styling.md` — customizing components via `className`, `style`, `data-*`, CSS Modules, and CVA - `references/composition.md` — compound dot-notation pattern, Base UI trigger/overlay composition, `render` prop, and per-component composition gotchas diff --git a/.agents/skills/apsara/references/components.md b/.agents/skills/apsara/references/components.md index 80a152127..e13a8173a 100644 --- a/.agents/skills/apsara/references/components.md +++ b/.agents/skills/apsara/references/components.md @@ -129,9 +129,9 @@ All components import from the root: `import { Button, Dialog } from "@raystack/ | Export | Purpose | |---|---| -| `Theme` (alias `ThemeProvider`, deprecated) | Theme provider — wrap the app. See `theming.md`. | +| `Theme` | Theme element — wrap the app, nest to scope. See `theming.md`. | | `ThemeSwitcher` | Prebuilt light/dark toggle button | -| `useTheme` | Hook to read/set theme (also at `@raystack/apsara/hooks`) | +| `useTheme` | Hook to read/set the theme; throws outside a `Theme` | ## Notes diff --git a/.agents/skills/apsara/references/setup.md b/.agents/skills/apsara/references/setup.md index 273cd17d9..ba08c5f1f 100644 --- a/.agents/skills/apsara/references/setup.md +++ b/.agents/skills/apsara/references/setup.md @@ -35,27 +35,27 @@ import "@raystack/apsara/style.css"; ## 3. Wrap the app in `` -The `Theme` provider applies `data-theme` / `data-style` / `data-accent-color` / `data-gray-color` to the document and injects a small inline script that sets them **before paint** to avoid a flash of the wrong theme. Tokens only resolve correctly inside it. +`Theme` renders an element carrying `data-theme` / `data-accent-color` / `data-gray-color` / `data-radius` / `data-scaling` / `data-panel-background` / `data-reduced-motion`, and every `--rs-*` token is declared under those attributes. **Tokens only resolve inside it**, so it has to wrap anything that reads them. ```tsx import { Theme } from "@raystack/apsara"; function App() { return ( - + ); } ``` -`defaultTheme` accepts `"light"`, `"dark"`, or `"system"` (follows OS preference). See `theming.md` for the full prop list (accent color, gray color, style variant, storage key, forced theme, etc.). +`defaultValue` seeds the seven settings; `appearance` accepts `"light"`, `"dark"` or `"system"` (follows OS preference). `persistKey` turns on persistence and names its storage entry — without it, settings live in memory only. See `theming.md` for the full settings and prop list. ## Framework wiring ### Next.js (App Router) -Put the CSS import and provider in the root layout. `suppressHydrationWarning` on `` is **required** because the no-flash script mutates `` attributes before React hydrates. +Put the CSS import and the theme in the root layout. `suppressHydrationWarning` on `` is **not** needed: nothing is written to ``, and `Theme` marks its own element. ```tsx // app/layout.tsx @@ -64,9 +64,9 @@ import "@raystack/apsara/style.css"; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - + - {children} + {children} ); @@ -74,7 +74,8 @@ export default function RootLayout({ children }: { children: React.ReactNode }) ``` Notes for the App Router: -- The CSS import and `Theme` can live in a server component (layout); `Theme` itself is a client component (`"use client"`) and handles that boundary internally. +- The CSS import and `Theme` can live in a server component (layout); `Theme` itself is a client component (`"use client"`) and handles that boundary internally. It server-renders its attributes on the first byte. +- Passing `icons` requires a client component, because an override map is an object of functions. Move the theme into a `providers.tsx` marked `'use client'` in that case. - Interactive Apsara components are client components — render them within client boundaries as usual. ### Vite / CRA / SPA @@ -91,7 +92,7 @@ import App from "./App"; ReactDOM.createRoot(document.getElementById("root")!).render( - + @@ -102,16 +103,16 @@ ReactDOM.createRoot(document.getElementById("root")!).render( | Import | Contents | |---|---| -| `@raystack/apsara` | All components, the `Theme` provider, `toastManager`/`useToastManager`, type exports | -| `@raystack/apsara/icons` | Icon set (re-exports + Apsara icons) | -| `@raystack/apsara/hooks` | Utility hooks (e.g. `useTheme`) | +| `@raystack/apsara` | All components, `Theme`/`useTheme`/`ThemeSwitcher`, `toastManager`/`useToastManager`, type exports | +| `@raystack/apsara/icons` | The 31 icons Apsara's components draw, plus `createIcon` | +| `@raystack/apsara/hooks` | Utility hooks (`useCopyToClipboard`, `useDebouncedState`, `useIsomorphicLayoutEffect`, `useMouse`) | | `@raystack/apsara/style.css` | The full stylesheet (required) | | `@raystack/apsara/normalize.css` | Optional CSS reset | ```tsx import { Button } from "@raystack/apsara"; import { MagnifyingGlassIcon, Cross2Icon } from "@raystack/apsara/icons"; -import { useTheme } from "@raystack/apsara/hooks"; // also re-exported from the root +import { useCopyToClipboard } from "@raystack/apsara/hooks"; ``` > `@raystack/apsara/v1` is an alias of the root entry kept for compatibility; new code should import from `@raystack/apsara`. @@ -137,6 +138,6 @@ export function Example() { ## Setup troubleshooting - **Components render unstyled / tokens are blank** → `style.css` isn't imported, or it's imported after a CSS reset that overrides it. Import it once at the root. -- **Colors don't change with light/dark, or `var(--rs-color-*)` resolves to nothing** → the tree isn't wrapped in ``, so `data-theme` is never set on the document. -- **Theme flashes wrong on first paint (Next.js)** → missing `suppressHydrationWarning` on ``, or `Theme` isn't high enough in the tree. -- **Hydration mismatch warnings around theme** → expected without `suppressHydrationWarning`; add it to ``. +- **Colors don't change with light/dark, or `var(--rs-color-*)` resolves to nothing** → the element isn't inside ``. Tokens are declared on the theme element, not ``, so anything outside it — including a hand-rolled portal into `document.body` — has no colors at all. +- **`useTheme` throws** → it is being called outside a ``. That is deliberate; move the caller inside. +- **Theme flashes wrong on first paint** → `Theme` has no `persistKey`, so it emits no pre-hydration script, or it isn't high enough in the tree. diff --git a/.agents/skills/apsara/references/styling.md b/.agents/skills/apsara/references/styling.md index 8dd491553..7d8136a2f 100644 --- a/.agents/skills/apsara/references/styling.md +++ b/.agents/skills/apsara/references/styling.md @@ -47,22 +47,22 @@ Common attributes: `data-open`, `data-closed`, `data-active`, `data-disabled`, ` ## Theme-conditional styling -`` sets `data-theme` / `data-style` / `data-accent-color` / `data-gray-color` on the root (and scope wrappers). Target them for theme-specific overrides: +`` writes one attribute per setting — `data-theme`, `data-accent-color`, `data-gray-color`, `data-radius`, `data-scaling`, `data-panel-background`, `data-reduced-motion` — onto the element it renders. Match an ancestor rather than ``, so a nested scope styles correctly too: ```css [data-theme="dark"] .custom-card { border-color: var(--rs-color-border-base-tertiary); } -[data-style="traditional"] .heading { font-family: var(--rs-font-title); } +[data-accent-color="orange"] .heading { color: var(--rs-color-foreground-accent-primary); } ``` ## Overriding tokens (custom palette / sizing) To re-skin globally or per-scope, redefine **semantic** tokens under a selector. Prefer semantic tokens over raw scale steps so the override stays theme-correct. +Every `--rs-*` declaration is wrapped in `:where()` and every theme element carries the stable `rs-theme` class, so one class selector wins without `!important`. + ```css -/* Make the danger emphasis fill a custom red across the app */ -:root, -[data-theme="light"], -[data-theme="dark"] { +/* Make the accent emphasis fill a custom color across the app */ +.rs-theme { --rs-color-background-accent-emphasis: var(--rs-color-viz-iris-9); } ``` diff --git a/.agents/skills/apsara/references/theming.md b/.agents/skills/apsara/references/theming.md index 35948f9ce..5f3d15a30 100644 --- a/.agents/skills/apsara/references/theming.md +++ b/.agents/skills/apsara/references/theming.md @@ -1,64 +1,74 @@ # Theming -Use this for anything involving the `Theme` provider, light/dark mode, accent/gray colors, the modern/traditional style variant, reading or changing the theme at runtime, or scoping a theme to part of the tree. +Use this for anything involving the `Theme` component, light/dark mode, accent/gray colors, radius, scaling, panel background, reduced motion, reading or changing the theme at runtime, or scoping a theme to part of the tree. -## The `Theme` provider +## The `Theme` component -`Theme` (aliased as the deprecated `ThemeProvider`) configures the active theme and writes `data-*` attributes that the token CSS keys off. Mount it once near the root (see `setup.md`). It can also be nested to scope a theme to a subtree (see "Scoped themes" below). +`Theme` renders an element that carries every token-bearing `data-*` attribute, and the token CSS keys off those attributes. Mount it once near the root (see `setup.md`). It can also be nested to scope a theme to a subtree, and a portalled popup re-emits the scope it was opened in, so scoping works everywhere. + +Tokens live on that element, so **anything reading `--rs-*` must be inside it**. Nothing is written to ``. ```tsx import { Theme } from "@raystack/apsara"; ``` -### Props +### Settings -| Prop | Type | Default | Notes | +One object, seven keys. Each can be seeded, controlled or persisted on its own, and each becomes a data attribute on the theme element. + +| Setting | Values | Default | Attribute | |---|---|---|---| -| `defaultTheme` | `string` | `"system"` (or `"light"` if `enableSystem` is false) | Initial theme when nothing is stored. | -| `forcedTheme` | `string` | — | Locks the page to a theme; ignores storage and system. | -| `enableSystem` | `boolean` | `true` | Follow `prefers-color-scheme` when theme is `"system"`. | -| `enableColorScheme` | `boolean` | `true` | Sets `color-scheme` so native UI (scrollbars, inputs) matches. | -| `disableTransitionOnChange` | `boolean` | `false` | Suppress CSS transitions during a theme switch (no flicker). | -| `storageKey` | `string` | `"theme"` | localStorage key for the persisted choice. | -| `themes` | `string[]` | `["light","dark"]` | Allowed theme names. | -| `attribute` | `string \| "class"` | `"data-theme"` | Which HTML attribute reflects the theme. | -| `value` | `Record` | — | Map theme name → attribute value. | -| `nonce` | `string` | — | CSP nonce for the inline no-flash script. | -| `style` | `"modern" \| "traditional"` | `"modern"` | Style variant — controls radius scale and fonts. | -| `accentColor` | `"indigo" \| "orange" \| "mint"` | `"indigo"` | Brand accent ramp. | -| `grayColor` | `"gray" \| "mauve" \| "slate" \| "sage"` | `"gray"` | Neutral ramp. | -| `onThemeChange` | `(theme, resolvedTheme) => void` | — | Fires on change (not on initial mount). `resolvedTheme` is `"light"`/`"dark"` when theme is `"system"`. | - -These attributes land on `` (root provider) and drive the tokens: - -| Attribute | Values | -|---|---| -| `data-theme` | `light`, `dark` | -| `data-style` | `modern`, `traditional` | -| `data-accent-color` | `indigo`, `orange`, `mint` | -| `data-gray-color` | `gray`, `mauve`, `slate`, `sage` | +| `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` and `data-gray-color` is never `auto`. `grayColor: "auto"` pairs a gray to the accent (indigo→slate, orange→mauve, mint→sage). + +Fonts are **not** a setting. They are the `--rs-font-body` / `--rs-font-title` / `--rs-font-mono` CSS variables; redeclare them on `.rs-theme`. + +### Props + +| Prop | Type | Notes | +|---|---|---| +| `defaultValue` | `Partial` | Seeds uncontrolled keys. A stored user choice overrides it. | +| `value` | `Partial` | Per-key control. A controlled key always wins and is never persisted. | +| `onValueChange` | `(value, changed) => void` | Fires when `setValue` requests a change. Controlled keys are reported but not applied; storage-driven changes do not fire it. | +| `persist` | `ThemeSettingKey[]` | Which settings the namespace covers. Defaults to all seven. | +| `persistKey` | `string` | Storage namespace. **Persistence is off unless this is set.** | +| `isRoot` | `boolean` | Whether this theme owns the document's color scheme. Defaults to true when there is no ancestor theme; an embedded widget should pass `false`. | +| `hasBackground` | `boolean` | Overrides the paint heuristic (true at the root and for a nested theme with its own `light`/`dark`, false otherwise). | +| `disableTransitionOnChange` | `boolean` | Suppresses the crossfade during an appearance switch. | +| `nonce` | `string` | CSP nonce for the inline script. | +| `icons` | `IconOptions` | Replaces the drawings inside Apsara's components and sets the props every icon receives. See `components.md` and the Icons docs. | +| `render` | element or function | `asChild`-style escape hatch; merges the theme onto your own element. | +| `className`, `style` | — | The stable `rs-theme` class is always present alongside your classes. | + +Control is per key: drive `appearance` from a cookie while leaving accent and radius uncontrolled and persisted. ## Reading & changing the theme: `useTheme` -Exported from both `@raystack/apsara` and `@raystack/apsara/hooks`. Must be called under a `Theme` provider. +Must be called inside a `Theme`. It **throws** outside one, because no color token resolves there. ```tsx import { useTheme } from "@raystack/apsara"; -function ThemeToggle() { - const { theme, resolvedTheme, setTheme } = useTheme(); +function AppearanceToggle() { + const { resolved, setValue } = useTheme(); + const isDark = resolved.appearance === "dark"; return ( - ); } @@ -66,53 +76,63 @@ function ThemeToggle() { Returned values: -- `theme` — the active selection (may be `"system"`). -- `resolvedTheme` — the actually applied theme: `forcedTheme` if set; otherwise `"light"`/`"dark"` (resolving `"system"`); otherwise equals `theme`. **Use this for conditional UI**, not `theme`. -- `systemTheme` — OS preference (`"light"`/`"dark"`) when `enableSystem`. -- `setTheme(name)` — update + persist the nearest scope's theme. At the root this persists the user's choice. Passing `undefined` inside a scope clears that scope's storage and re-inherits from the parent. -- `style`, `accentColor`, `grayColor` — the nearest provider's effective values. -- `forcedTheme`, `themes`. - -`useTheme({ storageKey })` targets a specific provider (the root or a named scope) instead of the nearest one — useful for flipping the page theme from inside a scoped subtree. +- `value` — settings as set, `system` and `auto` included. +- `resolved` — settings as applied, with `appearance` resolved against the OS and `grayColor` against the accent. **Use this for conditional UI**, not `value`. +- `setValue(partial)` — applies a partial settings object. Controlled keys are reported to `onValueChange` but not applied. +- `systemAppearance` — what the OS reports, whatever the current setting is. +- `root` — the same handle bound to the root provider, for flipping the page theme from inside a scope. ## `ThemeSwitcher` -A minimal prebuilt sun/moon toggle button (toggles light↔dark via `useTheme`): +A prebuilt sun/moon icon button that flips light↔dark. It reads `resolved`, so `system` shows what is actually on screen. ```tsx import { ThemeSwitcher } from "@raystack/apsara"; - + // flips the nearest scope + // flips the page ``` -For anything richer (system option, accent pickers), build your own control on top of `useTheme`. +For anything richer (a system option, accent pickers), build your own control on `useTheme`. ## Scoped (nested) themes -Nesting `` scopes overrides to a subtree without affecting the rest of the page. A scope renders a wrapper `
` carrying the layered `data-*` attributes, and `useTheme()` inside it sees the scope's effective values. +Nesting `Theme` scopes overrides to a subtree. A nested theme inherits every key it does not set, and `useTheme()` inside it sees the scope's effective values. ```tsx - + - {/* This panel is always light + orange accent, regardless of the page theme */} - + {/* Always light + orange, regardless of the page theme */} + ``` -- A scope with a `storageKey` persists and registers itself, so `useTheme({ storageKey })` can address it. -- A stateless scope with no overrides is a transparent pass-through (no wrapper, no extra context). -- `setTheme` from a scope updates only that scope and never propagates outward. +- Scaling does not compound: `0.9` inside `0.9` is `0.9`, not `0.81`. +- A nested theme paints a background only when it sets its own `light` or `dark`; one that only re-tints stays transparent. Override with `hasBackground`. +- A popup (Popover, Select, Menu, Tooltip, Dialog…) opened inside a scope carries that scope's theme onto the portalled element. +- Persistence is per `persistKey`, so a scope, a widget and a second root each keep their own state by default. Two themes sharing a key stay in step. + +## Persistence and first paint + +Set `persistKey` to persist; without it settings live in memory only. `persist` narrows which keys the namespace covers. + +```tsx + +``` + +A theme with a `persistKey` emits a small inline script as its first child, which patches its own element before the rest of the page is parsed — no flash, and nothing written to ``. Accent, gray, radius, scaling and panel background server-render correctly on the first byte, so the script usually covers `appearance` alone. `` does **not** need `suppressHydrationWarning`. ## Custom accent / palette beyond the presets -The built-in `accentColor`/`grayColor` presets cover the common cases. To go further, override the `--rs-*` color tokens under a theme selector in your own CSS (see `tokens.md` for the variable names and `styling.md` for override patterns). Prefer overriding **semantic** tokens (`--rs-color-background-accent-emphasis`) over raw scale steps. +The built-in `accentColor`/`grayColor` presets cover the common cases. To go further, override the `--rs-*` color tokens on `.rs-theme` in your own CSS (see `tokens.md` for names and `styling.md` for patterns). Every `--rs-*` declaration is wrapped in `:where()`, so one class selector wins without `!important`. Prefer **semantic** tokens (`--rs-color-background-accent-emphasis`) over raw scale steps. ## Theming pitfalls -- Conditional rendering should branch on `resolvedTheme`, not `theme` (which can be `"system"`). -- `setTheme(undefined)` is a no-op at the root; inside a persistent scope it clears + re-inherits. -- Next.js: missing `suppressHydrationWarning` on `` causes hydration warnings because the no-flash script pre-sets attributes (see `setup.md`). -- Changing `accentColor`/`grayColor`/`style` at runtime just swaps `data-*` attributes — all themed tokens update automatically; you don't need to re-import CSS. +- Conditional rendering should branch on `resolved`, not `value` (which can hold `system`/`auto`). +- `useTheme` throws outside a provider — it is not a no-op. Anything calling it must be inside `Theme`. +- Tokens are not on ``. CSS that declares custom properties on `:root` from `--rs-*` values, and hand-rolled portals rendered outside the tree, resolve to nothing. +- `:where()` means token declarations carry no specificity, so a consumer rule on `:root` now beats them. Scope overrides to `.rs-theme`. +- Changing `accentColor`/`grayColor`/`radius`/`scaling` at runtime just swaps `data-*` attributes — every token updates automatically, with no CSS re-import. diff --git a/.agents/skills/apsara/references/tokens.md b/.agents/skills/apsara/references/tokens.md index d4b0d0e1f..4a22655ad 100644 --- a/.agents/skills/apsara/references/tokens.md +++ b/.agents/skills/apsara/references/tokens.md @@ -119,17 +119,21 @@ Use for padding, margin, and gap. Values are in `px`. ## Radius — `--rs-radius-*` -Radius depends on `data-style`. `modern` is the default; `traditional` is rounder. +Each step is `base × scaling × radius factor`. The factor comes from the theme's `radius` setting: `none` → 0, `small` → 0.75, `medium` → 1 (default), `large` and `full` → 1.5. The values below are the defaults, at `radius="medium"` and `scaling="1"`. -| Token | `modern` | `traditional` | +| Token | Default | Base | |---|---|---| -| `--rs-radius-1` | 2px | 8px | -| `--rs-radius-2` | 4px | 16px | -| `--rs-radius-3` | 6px | 20px | -| `--rs-radius-4` | 8px | 24px | -| `--rs-radius-5` | 12px | 32px | -| `--rs-radius-6` | 16px | 40px | -| `--rs-radius-full` | 800px | 1600px | +| `--rs-radius-1` | 2px | 2px | +| `--rs-radius-2` | 4px | 4px | +| `--rs-radius-3` | 6px | 6px | +| `--rs-radius-4` | 8px | 8px | +| `--rs-radius-5` | 12px | 12px | +| `--rs-radius-6` | 16px | 16px | +| `--rs-radius-full` | 800px | fixed — follows neither factor | + +Two more pill tokens follow the setting directly: `--rs-radius-pill` is `9999px` only at `radius="full"` and `0` otherwise, so controls become pills only there; `--rs-radius-thumb` keeps round controls (`Switch`, `Slider`) round from `medium` up and squares them at `none` and `small`. + +Individual components take a `radius` prop that overrides the theme for that component alone, without compounding with it. --- @@ -213,18 +217,23 @@ Tokens: `--rs-font-size-t{1..4}`, `--rs-line-height-t{1..4}`, `--rs-letter-spaci ## Theme `data-*` attributes (for state/theme-conditional CSS) -Set by `` on the document root (and on scope wrappers): +Set by `` on the element it renders (the root theme and every nested scope alike): | 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`, `system` | + +`system` and `auto` never reach the DOM: they are resolved first, so `data-theme` is always `light` or `dark`. ```css [data-theme="dark"] .custom-card { border-color: var(--rs-color-border-base-tertiary); } -[data-style="traditional"] .hero { font-family: var(--rs-font-title); } +[data-accent-color="orange"] .hero { color: var(--rs-color-foreground-accent-primary); } ``` ## Token usage rules diff --git a/apps/www/package.json b/apps/www/package.json index 8fcd3f29a..f5ff8770c 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -24,7 +24,6 @@ "fumadocs-ui": "16.0.7", "lucide-react": "^1.45.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..39ad1740a 100644 --- a/apps/www/src/app/layout.tsx +++ b/apps/www/src/app/layout.tsx @@ -1,13 +1,12 @@ import { NextProvider } from 'fumadocs-core/framework/next'; import { Geist_Mono, Inter } from 'next/font/google'; import type { ReactNode } from 'react'; -import { ThemeProvider } from '@/components/theme'; +import { DocsTheme } from '@/components/theme'; import '@raystack/apsara/normalize.css'; 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/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/demo.tsx b/apps/www/src/components/demo/demo.tsx index 0aaeafe6c..2e261e16e 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/demo/no-inline.ts b/apps/www/src/components/demo/no-inline.ts new file mode 100644 index 000000000..125eb5fe6 --- /dev/null +++ b/apps/www/src/components/demo/no-inline.ts @@ -0,0 +1,9 @@ +/** + * Whether a demo has to run in react-live's `noInline` mode, which runs the + * snippet as a function body so it can declare helpers before the JSX. The two + * modes are mutually exclusive, so this keys off the `render(` call `noInline` + * requires. + */ +export function needsNoInline(code: string | undefined): boolean { + return /(^|[^.\w])render\s*\(/.test(code ?? ''); +} 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-panel-demo.tsx b/apps/www/src/components/theme-panel-demo.tsx new file mode 100644 index 000000000..4784ef4de --- /dev/null +++ b/apps/www/src/components/theme-panel-demo.tsx @@ -0,0 +1,222 @@ +'use client'; + +import { + Avatar, + Badge, + Button, + Callout, + Checkbox, + Chip, + Dialog, + Drawer, + Flex, + Input, + Menu, + Popover, + Progress, + Select, + Separator, + Switch, + Text, + Theme, + type ThemeSettings, + Tooltip, + useTheme +} from '@raystack/apsara'; +import { useState } from 'react'; + +/** + * Spelled out rather than imported: the package exports the settings, not a + * catalogue of their values. `satisfies` fails the build if one drifts. + */ +const SETTING_VALUES = { + appearance: ['light', 'dark', 'system'], + accentColor: ['indigo', 'orange', 'mint'], + grayColor: ['gray', 'mauve', 'slate', 'sage', 'auto'], + radius: ['none', 'small', 'medium', 'large', 'full'], + scaling: ['0.9', '0.95', '1', '1.05', '1.1'], + panelBackground: ['solid', 'translucent'], + reducedMotion: ['true', 'false', 'system'] +} as const satisfies { + [K in keyof ThemeSettings]: readonly ThemeSettings[K][]; +}; + +function Controls() { + const { value, resolved, setValue } = useTheme(); + + const field = ( + label: string, + key: K, + options: readonly string[] + ) => ( + + + {label} + + + + ); + + return ( + + {field('Appearance', 'appearance', SETTING_VALUES.appearance)} + {field('Accent', 'accentColor', SETTING_VALUES.accentColor)} + {field('Gray', 'grayColor', SETTING_VALUES.grayColor)} + {field('Radius', 'radius', SETTING_VALUES.radius)} + {field('Scaling', 'scaling', SETTING_VALUES.scaling)} + {field('Panel', 'panelBackground', SETTING_VALUES.panelBackground)} + {field('Reduced motion', 'reducedMotion', SETTING_VALUES.reducedMotion)} + + + + 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. + + + + + + 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. + + + + + + + ); +} + +/** `isRoot={false}`: one example on the page, not the page itself. */ +export default function ThemePanelDemo() { + return ( + + + + {/* Its own `height: 100%` resolves to 0 against a content-sized row. */} + + + + + ); +} diff --git a/apps/www/src/components/theme-switcher/theme-toggle.tsx b/apps/www/src/components/theme-switcher/theme-toggle.tsx index 594503c85..f88c9e62f 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, useTheme } 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 } = useTheme(); + 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..f46b26478 100644 --- a/apps/www/src/components/theme.tsx +++ b/apps/www/src/components/theme.tsx @@ -1,76 +1,22 @@ '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 { Theme } from '@raystack/apsara'; +import type { ReactNode } from 'react'; +import styles from './theme.module.css'; + +/** + * The docs' root theme, the same component the demos mount, so a nested `Theme` + * inherits appearance rather than resolving `system` on its own. Its inline + * script replaces `next-themes` for pre-hydration appearance. + */ +export function DocsTheme({ 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/(overview)/getting-started.mdx b/apps/www/src/content/docs/(overview)/getting-started.mdx index 58df9de1a..04b318c80 100644 --- a/apps/www/src/content/docs/(overview)/getting-started.mdx +++ b/apps/www/src/content/docs/(overview)/getting-started.mdx @@ -57,14 +57,14 @@ import { Theme } 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 @@ -101,9 +101,9 @@ import "@raystack/apsara/style.css"; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - + - + {children} @@ -112,7 +112,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) } ``` -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 @@ -128,7 +128,7 @@ import App from "./App"; ReactDOM.createRoot(document.getElementById("root")!).render( - + @@ -178,11 +178,12 @@ To read or change the active theme, use `useTheme` from the main entry: import { Button, useTheme } from "@raystack/apsara"; function ThemeToggle() { - const { resolvedTheme, setTheme } = useTheme(); + const { resolved, setValue } = useTheme(); + 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..e8acf6950 100644 --- a/apps/www/src/content/docs/(overview)/index.mdx +++ b/apps/www/src/content/docs/(overview)/index.mdx @@ -30,12 +30,12 @@ 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 `Theme` to enable appearance, accent and gray colors, radius, scaling, panel background and reduced motion. ```tsx import { Theme } from "@raystack/apsara"; - + ``` diff --git a/apps/www/src/content/docs/(overview)/styling.mdx b/apps/www/src/content/docs/(overview)/styling.mdx index dd4a4e876..29f470348 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: +`Theme` 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 51cbf7302..75b0e8fa7 100644 --- a/apps/www/src/content/docs/(overview)/upgrading.mdx +++ b/apps/www/src/content/docs/(overview)/upgrading.mdx @@ -7,6 +7,113 @@ 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: `Theme` is rewritten + +`Theme` used to put its tokens on `` from an effect, so it could not +server-render and only one could exist per page. It now mounts them on an +element it renders, which makes a scope, a portalled popup and the root the same +component, and adds radius, scaling, panel background and reduced-motion +settings. See [Theme](/docs/theme/overview). + +The component keeps its name but not its props, so migrate the whole +application at once rather than file by file. + +### 1. Reshape the props + +```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 the old `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 | `Theme` | + +`icons` is unchanged, and `ThemeSwitcher` keeps its name. + +`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. The `data-style` attribute and the Lora / Josefin Sans pairing it +selected are gone, along with the `--rs-font-lora` and `--rs-font-josefin-sans` +tokens. + +### 3. Reshape the hook + +`useTheme` keeps its name and returns a different shape. + +| Removed | Replacement | +|---|---| +| `useTheme().theme` / `.setTheme` / `.resolvedTheme` / `.systemTheme` | `value` / `setValue` / `resolved` / `systemAppearance` | +| `useTheme().themes` / `.forcedTheme` / `.style` / `.scopes` | None | +| `useTheme({ storageKey })` | `useTheme().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 } = useTheme(); +root.setValue({ appearance: "dark" }); +``` + +`useTheme` 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/components/alert-dialog/props.ts b/apps/www/src/content/docs/components/alert-dialog/props.ts index 0c3d626f1..9c81028dc 100644 --- a/apps/www/src/content/docs/components/alert-dialog/props.ts +++ b/apps/www/src/content/docs/components/alert-dialog/props.ts @@ -23,6 +23,12 @@ export interface AlertDialogContentProps { /** Additional CSS class names */ className?: string; + + /** + * Corner radius for this dialog only. Overrides the theme's `radius`. + * @defaultValue The theme's `radius` + */ + radius?: 'none' | 'small' | 'medium' | 'large' | 'full'; } export interface AlertDialogHeaderProps { diff --git a/apps/www/src/content/docs/components/avatar/props.ts b/apps/www/src/content/docs/components/avatar/props.ts index c8db83473..d2148e49a 100644 --- a/apps/www/src/content/docs/components/avatar/props.ts +++ b/apps/www/src/content/docs/components/avatar/props.ts @@ -20,12 +20,6 @@ export interface AvatarProps { */ variant?: 'solid' | 'soft'; - /** - * Border radius style - * @defaultValue "small" - */ - radius?: 'small' | 'full'; - /** * Color theme for the avatar */ @@ -55,6 +49,12 @@ export interface AvatarProps { /** Additional CSS class names */ className?: string; + + /** + * Corner radius for this avatar only. Overrides the theme's `radius`. + * @defaultValue The theme's `radius` + */ + radius?: 'none' | 'small' | 'medium' | 'large' | 'full'; } export interface AvatarGroupProps { diff --git a/apps/www/src/content/docs/components/badge/props.ts b/apps/www/src/content/docs/components/badge/props.ts index 16e893d85..cf77604b1 100644 --- a/apps/www/src/content/docs/components/badge/props.ts +++ b/apps/www/src/content/docs/components/badge/props.ts @@ -28,4 +28,10 @@ export interface BadgeProps { /** Additional CSS class names */ className?: string; + + /** + * Corner radius for this badge only. Overrides the theme's `radius`. + * @defaultValue The theme's `radius` + */ + radius?: 'none' | 'small' | 'medium' | 'large' | 'full'; } diff --git a/apps/www/src/content/docs/components/button/props.ts b/apps/www/src/content/docs/components/button/props.ts index 95ff545d5..f08e8cdf1 100644 --- a/apps/www/src/content/docs/components/button/props.ts +++ b/apps/www/src/content/docs/components/button/props.ts @@ -61,4 +61,10 @@ export type ButtonProps = { /** Additional CSS class names */ className?: string; + + /** + * Corner radius for this button only. Overrides the theme's `radius`. + * @defaultValue The theme's `radius` + */ + radius?: 'none' | 'small' | 'medium' | 'large' | 'full'; }; diff --git a/apps/www/src/content/docs/components/callout/props.ts b/apps/www/src/content/docs/components/callout/props.ts index 4a3b0ec68..e9d09584c 100644 --- a/apps/www/src/content/docs/components/callout/props.ts +++ b/apps/www/src/content/docs/components/callout/props.ts @@ -50,4 +50,10 @@ export interface CalloutProps { /** Additional CSS class names */ className?: string; + + /** + * Corner radius for this callout only. Overrides the theme's `radius`. + * @defaultValue The theme's `radius` + */ + radius?: 'none' | 'small' | 'medium' | 'large' | 'full'; } diff --git a/apps/www/src/content/docs/components/chip/props.ts b/apps/www/src/content/docs/components/chip/props.ts index 9f270cd68..7c7df54fd 100644 --- a/apps/www/src/content/docs/components/chip/props.ts +++ b/apps/www/src/content/docs/components/chip/props.ts @@ -45,4 +45,10 @@ export interface ChipProps { /** Custom accessibility label for the chip */ 'aria-label'?: string; + + /** + * Corner radius for this chip only. Overrides the theme's `radius`. + * @defaultValue The theme's `radius` + */ + radius?: 'none' | 'small' | 'medium' | 'large' | 'full'; } diff --git a/apps/www/src/content/docs/components/combobox/props.ts b/apps/www/src/content/docs/components/combobox/props.ts index 6afbf1c8b..6b60a7236 100644 --- a/apps/www/src/content/docs/components/combobox/props.ts +++ b/apps/www/src/content/docs/components/combobox/props.ts @@ -90,6 +90,12 @@ export interface ComboboxContentProps { /** Additional CSS class names. */ className?: string; + + /** + * Corner radius for this popup only. Overrides the theme's `radius`. + * @defaultValue The theme's `radius` + */ + radius?: 'none' | 'small' | 'medium' | 'large' | 'full'; } export interface ComboboxItemProps { diff --git a/apps/www/src/content/docs/components/command/props.ts b/apps/www/src/content/docs/components/command/props.ts index 1b8fc9751..d98976187 100644 --- a/apps/www/src/content/docs/components/command/props.ts +++ b/apps/www/src/content/docs/components/command/props.ts @@ -157,4 +157,10 @@ export interface CommandDialogContentProps { /** Explicit width for the dialog popup. */ width?: string | number; + + /** + * Corner radius for this palette only. Overrides the theme's `radius`. + * @defaultValue The theme's `radius` + */ + radius?: 'none' | 'small' | 'medium' | 'large' | 'full'; } diff --git a/apps/www/src/content/docs/components/context-menu/props.ts b/apps/www/src/content/docs/components/context-menu/props.ts index fc51c9856..e5ac87ed7 100644 --- a/apps/www/src/content/docs/components/context-menu/props.ts +++ b/apps/www/src/content/docs/components/context-menu/props.ts @@ -84,6 +84,12 @@ export interface ContextMenuContentProps { /** Additional CSS class names */ className?: string; + + /** + * Corner radius for this menu only. Overrides the theme's `radius`. + * @defaultValue The theme's `radius` + */ + radius?: 'none' | 'small' | 'medium' | 'large' | 'full'; } export interface ContextMenuItemProps { @@ -206,4 +212,10 @@ export interface ContextMenuSubContentProps { /** Additional CSS class names */ className?: string; + + /** + * Corner radius for this submenu only. Overrides the theme's `radius`. + * @defaultValue The theme's `radius` + */ + radius?: 'none' | 'small' | 'medium' | 'large' | 'full'; } diff --git a/apps/www/src/content/docs/components/dialog/props.ts b/apps/www/src/content/docs/components/dialog/props.ts index 0c1905183..daa968e26 100644 --- a/apps/www/src/content/docs/components/dialog/props.ts +++ b/apps/www/src/content/docs/components/dialog/props.ts @@ -35,6 +35,12 @@ export interface DialogContentProps { /** Additional CSS class names */ className?: string; + + /** + * Corner radius for this dialog only. Overrides the theme's `radius`. + * @defaultValue The theme's `radius` + */ + radius?: 'none' | 'small' | 'medium' | 'large' | 'full'; } export interface DialogHeaderProps { diff --git a/apps/www/src/content/docs/components/drawer/props.ts b/apps/www/src/content/docs/components/drawer/props.ts index 5e797afd4..bff6148de 100644 --- a/apps/www/src/content/docs/components/drawer/props.ts +++ b/apps/www/src/content/docs/components/drawer/props.ts @@ -54,4 +54,10 @@ export interface DrawerContentProps { /** Additional inline styles. */ style?: React.CSSProperties; + + /** + * Corner radius for this drawer only. Overrides the theme's `radius`. + * @defaultValue The theme's `radius` + */ + radius?: 'none' | 'small' | 'medium' | 'large' | 'full'; } diff --git a/apps/www/src/content/docs/components/icon-button/props.ts b/apps/www/src/content/docs/components/icon-button/props.ts index 9605ecaf4..69dafeacf 100644 --- a/apps/www/src/content/docs/components/icon-button/props.ts +++ b/apps/www/src/content/docs/components/icon-button/props.ts @@ -16,4 +16,10 @@ export interface IconButtonProps { /** onClick function triggered when iconButton is clicked. */ onClick?: (event: React.MouseEvent) => void; + + /** + * Corner radius for this button only. Overrides the theme's `radius`. + * @defaultValue The theme's `radius` + */ + radius?: 'none' | 'small' | 'medium' | 'large' | 'full'; } diff --git a/apps/www/src/content/docs/components/image/props.ts b/apps/www/src/content/docs/components/image/props.ts index 27e2cbbeb..2f353f861 100644 --- a/apps/www/src/content/docs/components/image/props.ts +++ b/apps/www/src/content/docs/components/image/props.ts @@ -12,10 +12,10 @@ export interface ImageProps { fit?: 'contain' | 'cover' | 'fill'; /** - * Border radius style - * @deafult none + * Corner radius for this image only. Overrides the theme's `radius`. + * @defaultValue "none" */ - radius?: 'none' | 'small' | 'medium' | 'full'; + radius?: 'none' | 'small' | 'medium' | 'large' | 'full'; /** URL of fallback image to show on error */ fallback?: string; diff --git a/apps/www/src/content/docs/components/input/props.ts b/apps/www/src/content/docs/components/input/props.ts index 71f686200..cd5a5dba4 100644 --- a/apps/www/src/content/docs/components/input/props.ts +++ b/apps/www/src/content/docs/components/input/props.ts @@ -54,4 +54,10 @@ export interface InputProps { /** Additional CSS class names. */ className?: string; + + /** + * Corner radius for this input only. Overrides the theme's `radius`. + * @defaultValue The theme's `radius` + */ + radius?: 'none' | 'small' | 'medium' | 'large' | 'full'; } diff --git a/apps/www/src/content/docs/components/menu/props.ts b/apps/www/src/content/docs/components/menu/props.ts index bda65d862..632c75f8a 100644 --- a/apps/www/src/content/docs/components/menu/props.ts +++ b/apps/www/src/content/docs/components/menu/props.ts @@ -86,6 +86,12 @@ export interface MenuContentProps { /** Additional CSS class names */ className?: string; + + /** + * Corner radius for this menu only. Overrides the theme's `radius`. + * @defaultValue The theme's `radius` + */ + radius?: 'none' | 'small' | 'medium' | 'large' | 'full'; } export interface MenuItemProps { @@ -208,4 +214,10 @@ export interface MenuSubContentProps { /** Additional CSS class names */ className?: string; + + /** + * Corner radius for this submenu only. Overrides the theme's `radius`. + * @defaultValue The theme's `radius` + */ + radius?: 'none' | 'small' | 'medium' | 'large' | 'full'; } diff --git a/apps/www/src/content/docs/components/popover/props.ts b/apps/www/src/content/docs/components/popover/props.ts index dbbfab1ac..3ffe417fa 100644 --- a/apps/www/src/content/docs/components/popover/props.ts +++ b/apps/www/src/content/docs/components/popover/props.ts @@ -53,6 +53,12 @@ export interface PopoverContentProps { /** Content to render inside the popover. */ children?: React.ReactNode; + + /** + * Corner radius for this popup only. Overrides the theme's `radius`. + * @defaultValue The theme's `radius` + */ + radius?: 'none' | 'small' | 'medium' | 'large' | 'full'; } export interface PopoverTriggerProps { diff --git a/apps/www/src/content/docs/components/preview-card/props.ts b/apps/www/src/content/docs/components/preview-card/props.ts index ebf20250a..1445b6251 100644 --- a/apps/www/src/content/docs/components/preview-card/props.ts +++ b/apps/www/src/content/docs/components/preview-card/props.ts @@ -192,6 +192,12 @@ export interface PreviewCardContentProps { /** Content to render inside the preview card. */ children?: React.ReactNode; + + /** + * Corner radius for this card only. Overrides the theme's `radius`. + * @defaultValue The theme's `radius` + */ + radius?: 'none' | 'small' | 'medium' | 'large' | 'full'; } export interface PreviewCardViewportProps { diff --git a/apps/www/src/content/docs/components/select/props.ts b/apps/www/src/content/docs/components/select/props.ts index 65325ba7b..80ddce754 100644 --- a/apps/www/src/content/docs/components/select/props.ts +++ b/apps/www/src/content/docs/components/select/props.ts @@ -81,6 +81,12 @@ export interface SelectContentProps { /** Additional CSS class names. */ className?: string; + + /** + * Corner radius for this popup only. Overrides the theme's `radius`. + * @defaultValue The theme's `radius` + */ + radius?: 'none' | 'small' | 'medium' | 'large' | 'full'; } export interface SelectItemProps { diff --git a/apps/www/src/content/docs/components/textarea/props.ts b/apps/www/src/content/docs/components/textarea/props.ts index c04776396..90b2bcb32 100644 --- a/apps/www/src/content/docs/components/textarea/props.ts +++ b/apps/www/src/content/docs/components/textarea/props.ts @@ -40,4 +40,10 @@ export interface TextAreaProps { /** Additional CSS class names. */ className?: string; + + /** + * Corner radius for this text area only. Overrides the theme's `radius`. + * @defaultValue The theme's `radius` + */ + radius?: 'none' | 'small' | 'medium' | 'large' | 'full'; } diff --git a/apps/www/src/content/docs/components/tooltip/props.ts b/apps/www/src/content/docs/components/tooltip/props.ts index e612d619f..a75693cd4 100644 --- a/apps/www/src/content/docs/components/tooltip/props.ts +++ b/apps/www/src/content/docs/components/tooltip/props.ts @@ -92,6 +92,12 @@ export interface TooltipContentProps { * `aria-label` when the labelling text lives elsewhere in the DOM. */ 'aria-labelledby'?: string; + + /** + * Corner radius for this tooltip only. Overrides the theme's `radius`. + * @defaultValue The theme's `radius` + */ + radius?: 'none' | 'small' | 'medium' | 'large' | 'full'; } export interface TooltipProviderProps { diff --git a/apps/www/src/content/docs/components/tour/props.ts b/apps/www/src/content/docs/components/tour/props.ts index a16a6b2a0..cf80107de 100644 --- a/apps/www/src/content/docs/components/tour/props.ts +++ b/apps/www/src/content/docs/components/tour/props.ts @@ -185,6 +185,12 @@ export interface TourContentProps { /** Static nodes, or a render function receiving the active step and actions. */ children?: React.ReactNode | ((props: unknown) => React.ReactNode); + + /** + * Corner radius for this card only. Overrides the theme's `radius`. + * @defaultValue The theme's `radius` + */ + radius?: 'none' | 'small' | 'medium' | 'large' | 'full'; } export interface TourOverlayProps { diff --git a/apps/www/src/content/docs/theme/overview/demo.ts b/apps/www/src/content/docs/theme/overview/demo.ts index 190cda603..1d8b0b834 100644 --- a/apps/www/src/content/docs/theme/overview/demo.ts +++ b/apps/www/src/content/docs/theme/overview/demo.ts @@ -1,16 +1,295 @@ '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 => ( + + + + {panelBackground}} /> + + 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. + + + + ))} + ` +}; + +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 + + + + + ); +} + +render();` +}; + +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 86e570f45..02c2fda86 100644 --- a/apps/www/src/content/docs/theme/overview/index.mdx +++ b/apps/www/src/content/docs/theme/overview/index.mdx @@ -1,373 +1,300 @@ --- title: Overview -description: Understanding the Apsara theming system and the Theme component. +description: Theming with the Theme component. 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. + -## Installation +Apsara's theming is built on CSS custom properties, called tokens. `Theme` 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. -Wrap your application with the `Theme` component: +## Usage ```tsx import { Theme } from "@raystack/apsara"; -function App() { +export default function App() { return ( - + ); } ``` -## Customization +Tokens live on the element `Theme` 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. -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. +## Settings -```tsx -// Clean, technical aesthetic - +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. -// Warm, editorial feel - +| 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` | -// Vibrant and fresh - -``` +`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). -See [API Reference](#api-reference) for all available props and options. +### Appearance -## Tokens + -Tokens follow two naming patterns: +### Accent color -**Semantic tokens** hold context-aware values that adapt to the theme: -``` ---rs-{category}-{property}-{variant}-{state} -``` +`grayColor: "auto"` pairs a gray to the accent. -**Scale tokens** hold numerical progressions: -``` ---rs-{category}-{step} -``` + -**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 +### Radius -**Using tokens in CSS:** +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. -```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); -} -``` - -**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 +### Scaling -**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`) +A zoom: spacing, radius, type and line height scale together. Borders and font weights do not. -**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"; - -export default function RootLayout({ children }) { - return ( - - - {children} - - - ); -} -``` +### Panel background -The `suppressHydrationWarning` is required because the theme script modifies the HTML element before React hydrates. +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. -## Scoped theming + -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 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`. -### Bare attribute +### Reduced motion -Scoping is implemented in CSS, so setting the attribute on any element opts in: +`system` follows `prefers-reduced-motion`. `"true"` collapses the duration tokens, which stops transitions and any animation timed by a token. ```tsx - - {/* Page is dark */} -
- {/* This subtree renders with light tokens */} - -
- + ``` -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. +## Nesting -### Nested `Theme` +A nested `Theme` inherits every key it does not set. -Render a `Theme` inside another `Theme` and the inner one switches to *scope mode*. + -For a typed convenience wrapper, nest `Theme`: +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. -```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 + -Two rules cover every case: +### Portals -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 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): - -```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: +## Controlled -| Field | Source | Value | -|---|---|---| -| `resolvedTheme` | inherited from page | `dark` | -| `accentColor` | own (scope) | `mint` | -| `grayColor` | inherited | `mauve` | -| `style` | inherited | `traditional` | +`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. -- 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. - -**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 */} - - - -``` - -- 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. +// Everything under one namespace + -**Empty nested, no scope created** - -```tsx - - {/* no props → no-op pass-through */} - - - +// Only the appearance + ``` -- The inner `Theme` does nothing: no wrapper, no new context. -- `useTheme()` inside `Card` returns the page's state. -- `setTheme()` inside `Card` flips the page. +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. -### Persistent scope +## Server rendering -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: +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 -import { Theme, useTheme } from "@raystack/apsara"; - -function ScopeToggle() { - const { theme, setTheme } = useTheme(); +// Next.js App Router: app/layout.tsx +import { Theme } from "@raystack/apsara"; +export default function RootLayout({ children }) { return ( - + + + {children} + + ); } - - - - - ``` -**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:** - -- 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. - -### Targeting a specific scope +Nothing is written to ``, so it needs no `suppressHydrationWarning`. -`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`: +## useTheme ```tsx import { useTheme } from "@raystack/apsara"; -function PageThemeButton() { - // Reaches past nearer scopes to the root (whose default storageKey is "theme"). - const { theme, setTheme } = useTheme({ storageKey: "theme" }); +function AppearanceToggle() { + const { resolved, setValue } = useTheme(); + const isDark = resolved.appearance === "dark"; + 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 +`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: -Every nesting case in one table. +```tsx +const { root } = useTheme(); +root.setValue({ appearance: "dark" }); +``` -| 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(…)` | +The hook throws outside a provider. -### Nested provider vs. bare attribute +### ThemeSwitcher -- 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. +An icon button that flips between light and dark. It follows `resolved.appearance`. -## API Reference + -The provider, the hook, and the ready-made switcher. + -### Theme +## Per-component radius -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. +Components accept a `radius` prop with the theme's five values. It affects only that component and does not compound with the theme radius. - + -### useTheme +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`. `Menu.SubmenuContent` and `ContextMenu.SubContent` take it too. It goes on the portalled part, not the root: ``. -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. +## Tokens -```tsx -import { useTheme } from "@raystack/apsara"; +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. -function ThemeToggle() { - const { theme, setTheme, resolvedTheme } = useTheme(); +``` +--rs-{category}-{property}-{variant}-{state} --rs-color-background-accent-emphasis +--rs-{category}-{step} --rs-space-5, --rs-radius-3 +``` - return ( - - ); +```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 hook accepts an optional options object to target a specific scope by its `storageKey` (see [Targeting a specific scope](#targeting-a-specific-scope)): +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 -And returns: +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; +} -### ThemeSwitcher +.marketing-page .rs-theme { + --rs-font-title: "Playfair Display", serif; +} +``` -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 `; - }; - - 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 ( - ', + keys: ['appearance'], + seed: LIGHT_SEED, + 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'], + seed: LIGHT_SEED, + 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'], + 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; + + 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'], + 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('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; + + 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'], + seed: LIGHT_SEED, + elementId: 'rs-theme-abc' + }) as string; + + run(source, element); + + expect(element.getAttribute('data-theme')).toBe('light'); + }); + + 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('dark'); + }); + + 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'], + seed: LIGHT_SEED, + 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'], + seed: LIGHT_SEED, + 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/__tests__/ssr.test.tsx b/packages/raystack/components/theme/__tests__/ssr.test.tsx new file mode 100644 index 000000000..07da0d78c --- /dev/null +++ b/packages/raystack/components/theme/__tests__/ssr.test.tsx @@ -0,0 +1,181 @@ +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 { useTheme } from '../context'; +import { clearThemeStorageCache } from '../store'; +import { Theme } from '../theme'; +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 + + ); + + 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 for a pinned appearance without persistence', () => { + const getItem = vi.spyOn(window.localStorage, 'getItem'); + 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( + + 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); + + 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); + }); + + 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 () => { + // React never fixes attribute mismatches on hydration; the mount effect does. + 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 = useTheme().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/__tests__/store.test.ts b/packages/raystack/components/theme/__tests__/store.test.ts new file mode 100644 index 000000000..3d7d8af5f --- /dev/null +++ b/packages/raystack/components/theme/__tests__/store.test.ts @@ -0,0 +1,218 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + clearThemeStorageCache, + readServerSettings, + readStoredSettings, + subscribeToThemeStorage, + THEME_STORAGE_EVENT, + writeStoredSettings +} from '../store'; +import { + installLocalStorage, + installThrowingLocalStorage, + 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 old provider stored `"dark"`: valid JSON, not an object, so detectable. + 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`; a fresh object would loop. + 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); + }); + + 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' }) + ).toBe(true); + }); + + it('reports success when storage already holds the value', () => { + entries.set('app', storedEntry({ appearance: 'dark' })); + expect( + writeStoredSettings('app', ['appearance'], { appearance: 'dark' }) + ).toBe(true); + }); + + it('reports failure when setItem throws, and notifies nobody', () => { + vi.spyOn(window.localStorage, 'setItem').mockImplementation(() => { + throw new DOMException('Quota exceeded', 'QuotaExceededError'); + }); + let notified = 0; + const unsubscribe = subscribeToThemeStorage(() => { + notified += 1; + }); + expect( + writeStoredSettings('app', ['appearance'], { appearance: 'dark' }) + ).toBe(false); + unsubscribe(); + expect(notified).toBe(0); + expect(entries.has('app')).toBe(false); + }); +}); + +describe('when storage access itself throws', () => { + beforeEach(() => { + installThrowingLocalStorage(); + }); + + it('reads as nothing stored', () => { + expect(readStoredSettings('app')).toEqual({}); + }); + + it('reports the write as failed', () => { + expect( + writeStoredSettings('app', ['appearance'], { appearance: 'dark' }) + ).toBe(false); + }); +}); + +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/__tests__/theme.test.tsx b/packages/raystack/components/theme/__tests__/theme.test.tsx new file mode 100644 index 000000000..6e04ebaf8 --- /dev/null +++ b/packages/raystack/components/theme/__tests__/theme.test.tsx @@ -0,0 +1,1184 @@ +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { type ReactNode, type SVGProps, useEffect } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { XIcon } from '~/icons'; +import { radiusStyle } from '../../../shared/radius'; +import { Dialog } from '../../dialog'; +import { useTheme } from '../context'; +import { useThemeInjection } from '../portal'; +import type { ThemeSettings } from '../settings'; +import { clearThemeStorageCache } from '../store'; +import { Theme } from '../theme'; +import { + installLocalStorage, + installMatchMedia, + installThrowingLocalStorage, + type MediaController, + storedEntry +} from './mocks'; + +let entries: Map; +let media: MediaController; + +beforeEach(() => { + entries = installLocalStorage(); + media = installMatchMedia(false); + clearThemeStorageCache(); +}); + +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 = useTheme(); + 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); +} + +describe('Theme 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'); + expect(scope).toHaveAttribute('data-radius', 'large'); + }); +}); + +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('paints a nested theme whose appearance comes from storage', () => { + entries.set('panel', storedEntry({ appearance: 'dark' })); + const { container } = render( + + + panel + + + ); + const panel = themeElement(container, 1); + expect(panel).toHaveAttribute('data-theme', 'dark'); + expect(panel).toHaveAttribute('data-rs-background'); + }); + + it('starts painting once a nested theme gets an appearance at runtime', async () => { + const user = userEvent.setup(); + function Switcher() { + const { setValue } = useTheme(); + return ( + + ); + } + const { container } = render( + + + + + + ); + const panel = themeElement(container, 1); + expect(panel).not.toHaveAttribute('data-rs-background'); + + await user.click(screen.getByRole('button')); + + expect(panel).toHaveAttribute('data-theme', 'dark'); + expect(panel).toHaveAttribute('data-rs-background'); + }); + + it('honours an explicit override', () => { + const { container } = render(content); + expect(themeElement(container)).not.toHaveAttribute('data-rs-background'); + }); +}); + +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 } = useTheme(); + 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' + }); + }); +}); + +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 } = useTheme(); + return ( + + ); + } + + const { container } = render( + + + + ); + await user.click(screen.getByRole('button')); + + expect(themeElement(container)).toHaveAttribute('data-theme', 'dark'); + expect(getItem).not.toHaveBeenCalled(); + expect(setItem).not.toHaveBeenCalled(); + }); + + 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); + 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 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() { + const { setValue } = useTheme(); + 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 } = useTheme(); + return ( + + ); + } + + const { container } = render( + <> + + + + + second + + + ); + + await user.click(screen.getByRole('button')); + + // `storage` never fires in the writing document; the in-document event does. + 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 } = useTheme(); + renders.push(`${resolved.appearance}/${resolved.radius}`); + return null; + } + + render( + + + + ); + + 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'); + }); +}); + +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 } = useTheme(); + return ( + {`${systemAppearance}/${resolved.appearance}`} + ); + } + render( + + + + ); + expect(screen.getByTestId('sys')).toHaveTextContent('dark/light'); + }); +}); + +describe('useTheme', () => { + 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 } = useTheme(); + return ( + + ); + } + + const { container } = render( + + + + + + ); + + await user.click(screen.getByRole('button')); + + expect(themeElement(container, 0)).toHaveAttribute('data-theme', 'dark'); + 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 = useTheme(); + return ( + {theme.root.resolved.accentColor} + ); + } + render( + + + + ); + expect(screen.getByTestId('root')).toHaveTextContent('mint'); + }); +}); + +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 } = useTheme(); + 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' }); + }); + + it('reports a controlled key without applying it', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + + function Switcher() { + const { setValue } = useTheme(); + return ( + + ); + } + + const { container } = render( + + + + ); + await user.click(screen.getByRole('button')); + + expect(onValueChange).toHaveBeenCalledTimes(1); + const [next, changed] = onValueChange.mock.calls[0]; + expect(next.appearance).toBe('light'); + expect(changed).toEqual({ appearance: 'light' }); + expect(themeElement(container)).toHaveAttribute('data-theme', 'dark'); + }); + + it('does not fire for a value that is already set', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + + function Switcher() { + const { setValue } = useTheme(); + return ( + + ); + } + + render( + + + + ); + await user.click(screen.getByRole('button')); + + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it('does not fire for a change that arrives from storage', () => { + const onValueChange = vi.fn(); + const { container } = render( + + content + + ); + + act(() => { + entries.set('app', storedEntry({ accentColor: 'mint' })); + window.dispatchEvent(new Event('storage')); + }); + + expect(themeElement(container)).toHaveAttribute( + 'data-accent-color', + 'mint' + ); + expect(onValueChange).not.toHaveBeenCalled(); + }); +}); + +describe('when storage is unavailable', () => { + it('still applies a persisted setting, in memory', async () => { + installThrowingLocalStorage(); + const user = userEvent.setup(); + + function Switcher() { + const { setValue } = useTheme(); + return ( + + ); + } + + const { container } = render( + + + + ); + expect(themeElement(container)).toHaveAttribute('data-theme', 'light'); + + await user.click(screen.getByRole('button')); + + expect(themeElement(container)).toHaveAttribute('data-theme', 'dark'); + }); +}); + +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); + }); +}); + +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'); + 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'); + }); + + // The backdrop is a sibling of the popup, so 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' + ); + }); + + // Both portals land under as siblings, so the inner dialog takes its + // theme from where it was declared, not from where it renders. + it('keeps a nested dialog on its own scope, not the dialog that opened it', async () => { + const user = userEvent.setup(); + render( + + + outer} /> + + Outer + + + inner} /> + + Inner + + + + + + + ); + + await user.click(screen.getByRole('button', { name: 'outer' })); + await user.click(screen.getByRole('button', { name: 'inner' })); + + const scopeOf = (element: Element) => { + const scope = element.closest('[data-theme]'); + return [ + scope?.getAttribute('data-theme'), + scope?.getAttribute('data-accent-color') + ]; + }; + + // Base UI renders no backdrop for a nested dialog; the parent's serves both. + const backdrops = [ + ...document.querySelectorAll('[data-slot="dialog-backdrop"]') + ]; + expect(backdrops).toHaveLength(1); + expect(scopeOf(backdrops[0])).toEqual(['dark', 'mint']); + + const titles = [...document.querySelectorAll('[data-slot="dialog-title"]')]; + expect(titles.map(t => [t.textContent, ...scopeOf(t)])).toEqual([ + ['Outer', 'dark', 'mint'], + ['Inner', 'light', 'orange'] + ]); + }); + + 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' + ); + }); +}); + +describe('the shared radius override', () => { + it('maps each level to its own class', () => { + expect(radiusStyle({ radius: 'none' })).toBeTruthy(); + expect(radiusStyle({ radius: 'full' })).toBeTruthy(); + expect(radiusStyle({ radius: 'small' })).not.toBe( + radiusStyle({ radius: 'large' }) + ); + }); + + it('returns nothing when the prop is unset', () => { + expect(radiusStyle({ radius: undefined })).toBe(''); + expect(radiusStyle({ radius: null })).toBe(''); + expect(radiusStyle({})).toBe(''); + }); +}); + +describe('disableTransitionOnChange', () => { + it('suppresses transitions across an appearance switch', async () => { + const user = userEvent.setup(); + function Switcher() { + const { setValue } = useTheme(); + return ( + + ); + } + + render( + + + + ); + + const before = document.head.querySelectorAll('style').length; + await act(async () => { + await user.click(screen.getByRole('button')); + }); + // The guard style is removed on the next tick, so assert cleanup, not presence. + 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); + }); +}); + +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 } = useTheme(); + 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 } = useTheme(); + 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(); + }); + + it('skips the crossfade for a scope, which repaints no more than itself', async () => { + const user = userEvent.setup(); + const vt = stubViewTransition(); + + const { container } = render( + + + + + + ); + await act(async () => { + await user.click(screen.getByRole('button')); + }); + expect(vt.startViewTransition).not.toHaveBeenCalled(); + expect(document.documentElement).not.toHaveAttribute(marker); + expect(themeElement(container, 1)).toHaveAttribute('data-theme', 'dark'); + expect(themeElement(container)).toHaveAttribute('data-theme', 'light'); + vt.restore(); + }); +}); + +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'); + }); +}); + +// The registry itself is tested in `icons/__tests__/registry.test.tsx`; these +// cover the wiring only. +describe('icons', () => { + const StubIcon = (props: 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: SVGProps) => ( + + ); + + render( + + + + + + ); + + expect(screen.getByTestId('inner')).toBeInTheDocument(); + expect(screen.queryByTestId('stub')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/raystack/components/theme/context.ts b/packages/raystack/components/theme/context.ts new file mode 100644 index 000000000..e7081cea1 --- /dev/null +++ b/packages/raystack/components/theme/context.ts @@ -0,0 +1,57 @@ +'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; + /** Partial settings. Controlled keys are reported, not applied. */ + 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 = 'ThemeContext'; + +/** The root provider's handle, carried past every nested scope. */ +export const RootThemeContext = createContext(null); +RootThemeContext.displayName = 'RootThemeContext'; + +export interface UseThemeReturn extends ThemeHandle { + /** The same shape bound to the root provider. */ + root: ThemeHandle; +} + +/** Nearest theme. Throws outside a provider, where no colour tokens exist. */ +export function useTheme(): UseThemeReturn { + const context = useContext(ThemeContext); + const root = useContext(RootThemeContext); + if (!context) { + throw new Error( + '`useTheme` 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/index.tsx b/packages/raystack/components/theme/index.tsx new file mode 100644 index 000000000..ff1551154 --- /dev/null +++ b/packages/raystack/components/theme/index.tsx @@ -0,0 +1,22 @@ +export { type ThemeHandle, type UseThemeReturn, useTheme } from './context'; +export { type ThemeInjectionProps, useThemeInjection } from './portal'; +export { createThemeScript, type ThemeScriptParams } from './script'; +export { + type AccentColor, + type Appearance, + type AppearanceSetting, + type GrayColor, + type GrayColorSetting, + type PanelBackground, + type Radius, + type ReducedMotion, + type ResolvedThemeSettings, + type Scaling, + type ThemeSettings +} from './settings'; +export { + ThemeSwitcher, + type ThemeSwitcherProps +} from './switcher'; +export { Theme, type ThemeProps } from './theme'; +export { useSystemAppearance } from './use-system-appearance'; diff --git a/packages/raystack/components/theme/portal.ts b/packages/raystack/components/theme/portal.ts new file mode 100644 index 000000000..c966c4137 --- /dev/null +++ b/packages/raystack/components/theme/portal.ts @@ -0,0 +1,33 @@ +'use client'; + +import { useMemo } from 'react'; + +import { useThemeContextOrNull } from './context'; +import { settingsToAttributes, THEME_CLASS } from './settings'; + +export interface ThemeInjectionProps { + className: string; + [attribute: string]: string; +} + +/** + * Re-emits the theme onto a portalled element; `undefined` outside a provider. + * Spread first, then pass `className` yourself: + * `` + * + * Spread it on the `Portal` too, not just the popup: parts that are siblings of + * the popup (a dialog's backdrop) draw tokens declared only under `[data-theme]` + * and render invisible without it. + */ +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/script.ts b/packages/raystack/components/theme/script.ts new file mode 100644 index 000000000..15335f016 --- /dev/null +++ b/packages/raystack/components/theme/script.ts @@ -0,0 +1,98 @@ +/** + * Pre-hydration script. Rendered as the theme element's first child, it patches + * its own parent before first paint with what the server could not know: stored + * values, and the OS answer for an unstored `system` appearance. + */ + +import { + GRAY_PAIRING, + SETTING_ATTRIBUTES, + STORAGE_VERSION, + SYSTEM_APPEARANCE_QUERY, + THEME_SETTING_KEYS, + THEME_SETTING_VALUES, + type ThemeSettingKey, + type ThemeSettings +} 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 `