Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions docs/guide/styling.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,59 @@ app.view(() => ui.text("Hello"));
await app.start();
```

Switching themes at runtime:

```typescript
app.setTheme(darkTheme);
```

Runtime guarantees for `setTheme`:

- it can be called before `start()` and while running
- it throws if called during render/commit
- it is a no-op when the effective theme identity is unchanged
- a theme change triggers a full redraw path

## Theme validation and extension

Theme hardening APIs are available from `@rezi-ui/core`:

- `validateTheme(theme)` for strict token validation
- `extendTheme(base, overrides)` for deep-merge inheritance + validation
- `contrastRatio(fg, bg)` for WCAG contrast calculations

Example:

```typescript
import { darkTheme, extendTheme, validateTheme } from "@rezi-ui/core";

const brandTheme = extendTheme(darkTheme, {
colors: { accent: { primary: { r: 255, g: 180, b: 84 } } },
});

validateTheme(brandTheme);
```

## Scoped theme overrides

`box`, `row`, and `column` accept a scoped `theme` override prop:

```typescript
import { ui } from "@rezi-ui/core";

ui.column({}, [
ui.text("parent"),
ui.box({ theme: { colors: { primary: { r: 90, g: 200, b: 140 } } } }, [ui.text("scoped")]),
ui.text("parent restored"),
]);
```

Behavior:

- nested scopes compose (inner override wins)
- exiting a scoped subtree restores parent theme
- partial overrides inherit unspecified parent tokens

See: [Theme](../styling/theme.md).

## Decision guide
Expand All @@ -79,6 +132,8 @@ Style is merged from parent → child:
- containers pass their resolved style to children
- leaf widgets merge their own `style` on top
- boolean attrs use tri-state semantics: `undefined` inherits, `false` disables, `true` enables
- `box`/`row`/`column` can also apply scoped `theme` overrides to descendants
- when container `style.bg` is set, that container rect is filled

Example:

Expand Down
225 changes: 100 additions & 125 deletions docs/styling/theme.md
Original file line number Diff line number Diff line change
@@ -1,160 +1,135 @@
# Theme

Rezi provides a semantic color token system with built-in theme presets.
Rezi supports two related theme shapes:

## Built-in Theme Presets
- `ThemeDefinition`: semantic tokens (`bg.base`, `fg.primary`, `accent.primary`, etc.)
- `Theme`: runtime flat palette used by the renderer

Six theme presets are available:
`app.setTheme(...)` accepts either shape.

| Preset | Description |
|--------|-------------|
| `darkTheme` | Ayu-inspired dark theme (default) |
| `lightTheme` | Clean light theme |
| `dimmedTheme` | Reduced contrast dark theme |
| `highContrastTheme` | WCAG AAA compliant |
| `nordTheme` | Nord color palette |
| `draculaTheme` | Dracula color palette |
## Built-in presets

Rezi ships six semantic presets:

- `darkTheme`
- `lightTheme`
- `dimmedTheme`
- `highContrastTheme`
- `nordTheme`
- `draculaTheme`

```typescript
import { darkTheme, lightTheme, nordTheme, draculaTheme } from "@rezi-ui/core";
import { darkTheme, nordTheme } from "@rezi-ui/core";

app.setTheme(darkTheme);
app.setTheme(nordTheme);
```

## Using Theme Colors
## Validation

Resolve theme tokens into RGB values for widget styling:
Use `validateTheme(theme)` to enforce required theme structure before use:

```typescript
import { darkTheme, resolveColorToken, ui } from "@rezi-ui/core";
import { validateTheme } from "@rezi-ui/core";

const primaryFg = resolveColorToken(darkTheme, "fg.primary");
const accentColor = resolveColorToken(darkTheme, "accent.primary");

ui.text("Hello", { style: { fg: primaryFg } })
ui.button({
id: "submit",
label: "Submit",
style: { fg: accentColor, bold: true },
})
validateTheme(myTheme);
```

## Color Token Paths

Theme definitions provide semantic color tokens:

| Token Path | Description |
|------------|-------------|
| `fg.primary` | Primary foreground text |
| `fg.secondary` | Secondary/dimmed text |
| `fg.muted` | Muted/disabled text |
| `fg.inverse` | Inverted foreground (for filled backgrounds) |
| `bg.base` | Base background |
| `bg.elevated` | Elevated surface background |
| `bg.overlay` | Overlay/popup background |
| `bg.subtle` | Subtle background variation |
| `accent.primary` | Primary accent color |
| `accent.secondary` | Secondary accent |
| `accent.tertiary` | Tertiary accent |
| `success` | Success/positive state |
| `warning` | Warning state |
| `error` | Error/negative state |
| `info` | Informational state |
| `focus.ring` | Focus indicator color |
| `focus.bg` | Focus background |
| `selected.bg` | Selected item background |
| `selected.fg` | Selected item foreground |
| `disabled.fg` | Disabled foreground |
| `disabled.bg` | Disabled background |
| `border.subtle` | Subtle border |
| `border.default` | Default border |
| `border.strong` | Strong/emphasized border |

## Runtime Theme (`Theme`)

The app runtime also uses a `Theme` object for:

- A spacing scale
- A small named color palette

You can provide it at app creation time:
Validation checks:

- All required semantic color tokens exist
- Every color token is valid RGB (`r/g/b` integer in `0..255`)
- Required spacing entries exist: `xs`, `sm`, `md`, `lg`, `xl`, `2xl`
- Focus indicator style tokens are present and valid

Error messages are path-specific, for example:

- `Theme validation failed at colors.accent.primary.r: ...`
- `Theme validation failed: missing required token path(s): colors.error, spacing.md`

## Extension / inheritance

Use `extendTheme(base, overrides)` to derive variants without cloning full objects:

```typescript
import { createTheme } from "@rezi-ui/core";
import { createNodeApp } from "@rezi-ui/node";
import { darkTheme, extendTheme } from "@rezi-ui/core";

const app = createNodeApp({
initialState: {},
theme: createTheme({ colors: { bg: { r: 10, g: 14, b: 20 } } }),
const brandDark = extendTheme(darkTheme, {
colors: {
accent: {
primary: { r: 255, g: 180, b: 84 },
},
},
});
```

Or change it at runtime:
Guarantees:

- deep merge (override wins, other tokens inherited)
- returns a new theme object
- does not mutate `base`
- validates merged output

## Contrast utility and WCAG checks

Use `contrastRatio(fg, bg)` for WCAG 2.1 contrast calculations:

```typescript
app.setTheme(createTheme({ colors: { primary: { r: 255, g: 180, b: 84 } } }));
import { contrastRatio } from "@rezi-ui/core";

const ratio = contrastRatio({ r: 0, g: 0, b: 0 }, { r: 255, g: 255, b: 255 }); // 21
```

## Creating Custom Themes
Built-in preset verification in tests:

- all six presets pass WCAG AA (`>= 4.5:1`) for primary `fg/bg`
- `highContrastTheme` passes WCAG AAA (`>= 7:1`) for primary `fg/bg`

## Runtime switching guarantees

`app.setTheme(nextTheme)` behavior:

Create custom themes with `createThemeDefinition()`:
- allowed before `start()` and while running
- throws on re-entrant render/commit calls (`ZRUI_UPDATE_DURING_RENDER`, `ZRUI_REENTRANT_CALL`)
- no-op when effective theme identity is unchanged
- theme changes trigger a full redraw (incremental reuse is bypassed on theme ref change)

## Component-level scoped overrides

`box`, `row`, and `column` support a scoped `theme` prop:

```typescript
import { createThemeDefinition, color } from "@rezi-ui/core";

const myTheme = createThemeDefinition("my-theme", {
bg: {
base: color(20, 20, 30),
elevated: color(30, 30, 40),
overlay: color(40, 40, 50),
subtle: color(25, 25, 35),
},
fg: {
primary: color(240, 240, 240),
secondary: color(180, 180, 180),
muted: color(100, 100, 100),
inverse: color(20, 20, 30),
},
accent: {
primary: color(100, 200, 255),
secondary: color(200, 100, 255),
tertiary: color(100, 255, 200),
},
success: color(100, 255, 100),
warning: color(255, 200, 100),
error: color(255, 100, 100),
info: color(100, 200, 255),
focus: {
ring: color(100, 200, 255),
bg: color(30, 40, 50),
},
selected: {
bg: color(50, 60, 80),
fg: color(240, 240, 240),
},
disabled: {
fg: color(80, 80, 80),
bg: color(25, 25, 35),
},
border: {
subtle: color(40, 40, 50),
default: color(60, 60, 70),
strong: color(100, 100, 110),
},
});
import { ui } from "@rezi-ui/core";

ui.column({}, [
ui.text("parent"),
ui.box({ theme: { colors: { primary: { r: 80, g: 200, b: 120 } } } }, [
ui.text("scoped"),
]),
ui.text("parent again"),
]);
```

## Resolution Helpers
Rules:

- scope applies to container subtree
- nested overrides compose (inner scope wins)
- leaving a scoped container restores parent theme
- partial overrides inherit unspecified parent tokens

## Color token helpers

```typescript
import { darkTheme, resolveColorToken, tryResolveColorToken } from "@rezi-ui/core";

const fg = resolveColorToken(darkTheme, "fg.primary");
const result = tryResolveColorToken(darkTheme, "accent.primary");
```

| Function | Description |
|----------|-------------|
| `resolveColorToken(theme, path)` | Resolve a token path to RGB |
| `tryResolveColorToken(theme, path)` | Resolve or return undefined |
| `resolveColorOrRgb(theme, colorOrPath)` | Accept RGB or token path |
| `isValidColorPath(path)` | Check if path is valid |
Related helpers:

## Related
- `resolveColorToken(theme, path)`
- `tryResolveColorToken(theme, path)`
- `resolveColorOrRgb(theme, colorOrPath, fallback)`
- `isValidColorPath(path)`

- [Style Props](style-props.md) - Styling widget props
- [Focus Styles](focus-styles.md) - Focus indicator configuration
- [Icons](icons.md) - Icon system
4 changes: 3 additions & 1 deletion packages/core/src/app/createApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1155,7 +1155,9 @@ export function createApp<S>(
assertOperational("setTheme");
if (inCommit) throwCode("ZRUI_REENTRANT_CALL", "setTheme: called during commit");
if (inRender) throwCode("ZRUI_UPDATE_DURING_RENDER", "setTheme: called during render");
theme = coerceToLegacyTheme(next);
const nextTheme = coerceToLegacyTheme(next);
if (nextTheme === theme) return;
theme = nextTheme;
requestRenderFromRenderer();
},

Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,14 +383,18 @@ export {
color,
createColorTokens,
createThemeDefinition,
DEFAULT_FOCUS_INDICATOR,
DEFAULT_THEME_SPACING,
type AccentTokens,
type BgTokens,
type BorderTokens,
type ColorTokens,
type DisabledTokens,
type FocusIndicatorTokens,
type FgTokens,
type FocusTokens,
type SelectedTokens,
type ThemeSpacingTokens,
type ThemeDefinition,
// Theme presets
darkTheme,
Expand All @@ -408,6 +412,12 @@ export {
isValidColorPath,
type ColorPath,
type ResolveColorResult,
// Validation and extension
validateTheme,
extendTheme,
type ThemeOverrides,
// Accessibility
contrastRatio,
} from "./theme/index.js";

// =============================================================================
Expand Down
Loading
Loading