diff --git a/.changeset/theme-schema-doc-rewrite-5648.md b/.changeset/theme-schema-doc-rewrite-5648.md new file mode 100644 index 000000000..52c05730c --- /dev/null +++ b/.changeset/theme-schema-doc-rewrite-5648.md @@ -0,0 +1,7 @@ +--- +--- + +Docs-only: rewrite `content/docs/core/theme-schema.mdx` around the retained theme +system (`Theme` from `@object-ui/types`, `ThemeEngine`, `ThemeProvider`) and delete +the page's three `check-doc-component-types.mjs` exemption entries (objectui#5648). +No published package changes. diff --git a/content/docs/core/theme-schema.mdx b/content/docs/core/theme-schema.mdx index 9374dc584..32651f4cf 100644 --- a/content/docs/core/theme-schema.mdx +++ b/content/docs/core/theme-schema.mdx @@ -1,23 +1,29 @@ --- -title: "Theme Schema (ThemeSchema)" -description: "Dynamic theming with light/dark modes, color palettes, and typography" +title: "Theme Schema (Theme)" +description: "Theming with the Theme document — color palettes, light/dark/auto modes, and CSS variables" --- import { SchemaExample } from '@/app/components/ComponentDemo'; # Theme Schema -The `ThemeSchema` provides a comprehensive theming system for ObjectUI applications, supporting light/dark modes, custom color palettes, typography, and CSS variable integration. +ObjectUI theming is driven by a **theme document** — a JSON object typed as `Theme` from +`@object-ui/types`. A theme is **not a component**: there is no `type: 'theme'` node to declare on +a page (the component wrapper this page documented until objectui#5489 was never implemented by any +renderer — declaring one produced an "Unknown component type" panel, never a theme manager). +Instead, the document is handed to `ThemeProvider`, which turns it into CSS custom properties and +applies them to the DOM. -## Overview +The theme system has three parts: -ThemeSchema enables: -- **Multiple theme definitions** - Create and switch between custom themes -- **Light/Dark modes** - Automatic mode switching with persistence -- **Color palettes** - 20+ semantic colors for consistent design -- **Typography system** - Font families, sizes, weights, and line heights -- **Tailwind integration** - Direct integration with Tailwind CSS -- **CSS variables** - Custom CSS variable support +- **`Theme`** (`@object-ui/types`) — the authoring document: colors, typography, border radii, + shadows, custom variables, inheritance. `@object-ui/types` owns this vocabulary: the spec + retired its theme module, and the shapes moved here (objectui#5716). +- **ThemeEngine** (`@object-ui/core`) — pure functions that convert a `Theme` into a CSS + custom-property map (`generateThemeVars`), resolve inheritance (`resolveThemeInheritance`) and + resolve the effective mode (`resolveMode`). +- **`ThemeProvider` / `useTheme`** (`@object-ui/react`) — the React context that injects the + variables, toggles the `light` / `dark` class, and optionally persists the user's choice. ## Interactive Examples @@ -31,339 +37,404 @@ ThemeSchema enables: ## Basic Usage -```plaintext -import type { ThemeSchema } from '@object-ui/types'; - -const theme: ThemeSchema = { - type: 'theme', - mode: 'dark', - themes: [ - { - name: 'professional', - label: 'Professional', - light: { - primary: '#3b82f6', - background: '#ffffff', - foreground: '#0f172a' - }, - dark: { - primary: '#60a5fa', - background: '#0f172a', - foreground: '#f1f5f9' - } - } - ], - activeTheme: 'professional', - allowSwitching: true, - persistPreference: true +```ts +import type { Theme } from '@object-ui/types'; + +const professional: Theme = { + name: 'professional', + label: 'Professional', + mode: 'auto', + colors: { + primary: '#3b82f6', + background: '#ffffff', + text: '#0f172a', + }, }; ``` -## Properties - -### Theme Configuration - -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| `type` | `'theme'` | - | Component type identifier (required) | -| `mode` | `'light' \| 'dark' \| 'system'` | `'light'` | Current theme mode | -| `themes` | `ThemeDefinition[]` | - | Available theme definitions | -| `activeTheme` | `string` | - | Currently active theme name | -| `allowSwitching` | `boolean` | `false` | Allow users to switch themes | -| `persistPreference` | `boolean` | `false` | Save theme preference to localStorage | -| `storageKey` | `string` | `'theme'` | Storage key for persisting theme | - -## Theme Definition - -```plaintext -interface ThemeDefinition { - name: string; // Theme identifier - label?: string; // Display name - light?: ColorPalette; // Light mode colors - dark?: ColorPalette; // Dark mode colors - typography?: Typography; - radius?: BorderRadius; - cssVariables?: Record; - tailwind?: Record; -} -``` +`name`, `label` and `colors` are required; `colors.primary` is the only required color. Everything +else is optional — an absent `mode` is treated as `'auto'`. -## Color Palette +## Applying a Theme -Semantic color tokens for consistent design: - -```plaintext -interface ColorPalette { - // Brand colors - primary?: string; - secondary?: string; - accent?: string; - - // Base colors - background?: string; - foreground?: string; - muted?: string; - mutedForeground?: string; - - // Component colors - card?: string; - cardForeground?: string; - popover?: string; - popoverForeground?: string; - - // UI elements - border?: string; - input?: string; - ring?: string; - - // Status colors - success?: string; - warning?: string; - destructive?: string; - info?: string; -} -``` +`ThemeProvider` wraps a subtree, registers the available themes, resolves inheritance and mode, +generates the CSS variables and injects them (on `document.documentElement` by default): -### Example Color Palette - -```json -{ - "light": { - "primary": "#3b82f6", - "secondary": "#64748b", - "accent": "#8b5cf6", - "background": "#ffffff", - "foreground": "#0f172a", - "muted": "#f1f5f9", - "mutedForeground": "#64748b", - "border": "#e2e8f0", - "input": "#e2e8f0", - "ring": "#3b82f6", - "success": "#10b981", - "warning": "#f59e0b", - "destructive": "#ef4444", - "info": "#3b82f6" - }, - "dark": { - "primary": "#60a5fa", - "secondary": "#94a3b8", - "accent": "#a78bfa", - "background": "#0f172a", - "foreground": "#f1f5f9", - "muted": "#1e293b", - "mutedForeground": "#94a3b8", - "border": "#334155", - "input": "#334155", - "ring": "#60a5fa", - "success": "#34d399", - "warning": "#fbbf24", - "destructive": "#f87171", - "info": "#60a5fa" - } +```tsx +import type { ReactNode } from 'react'; +import type { Theme } from '@object-ui/types'; +import { ThemeProvider } from '@object-ui/react'; + +const corporate: Theme = { + name: 'corporate', + label: 'Corporate', + colors: { primary: '#2563eb' }, +}; + +export function App({ children }: { children: ReactNode }) { + return ( + + {children} + + ); } ``` -## Typography System - -```plaintext -interface Typography { - fontSans?: string[]; // Sans-serif font stack - fontSerif?: string[]; // Serif font stack - fontMono?: string[]; // Monospace font stack - fontSize?: number; // Base font size (rem) - lineHeight?: number; // Base line height - headingWeight?: number; // Font weight for headings - bodyWeight?: number; // Font weight for body text +Inside the provider, `useTheme()` exposes the resolved state and the switching actions: + +```tsx +import { useTheme } from '@object-ui/react'; + +export function ThemeControls() { + const { resolvedMode, setMode, setTheme, themes } = useTheme(); + return ( +
+ + {themes.map((t) => ( + + ))} +
+ ); } ``` -### Example Typography - -```json -{ - "typography": { - "fontSans": ["Inter", "system-ui", "sans-serif"], - "fontSerif": ["Merriweather", "Georgia", "serif"], - "fontMono": ["JetBrains Mono", "monospace"], - "fontSize": 16, - "lineHeight": 1.5, - "headingWeight": 600, - "bodyWeight": 400 - } -} +`useTheme()` throws outside a provider; `useOptionalTheme()` returns `null` instead. + +### ThemeProvider Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `themes` | `Theme[]` | `[]` | Available theme documents | +| `defaultTheme` | `string` | first theme's `name` | Initially active theme | +| `defaultMode` | `ThemeMode` | `'auto'` | Initial mode | +| `persist` | `boolean` | `false` | Persist theme + mode to `localStorage` | +| `storageKey` | `string` | `'objectui-theme'` | `localStorage` key prefix (stored as `-name` / `-mode`) | +| `target` | `HTMLElement \| null` | `document.documentElement` | Element receiving the CSS variables and mode class | + +Persistence is a **provider** concern — there is no `persistPreference` or `storageKey` key on the +theme document itself. + +## Theme Properties + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `name` | `string` | yes | Unique theme identifier | +| `label` | `string` | yes | Human-readable display name | +| `description` | `string` | no | Optional description | +| `mode` | `'light' \| 'dark' \| 'auto'` | no | Display mode; absence means `'auto'` | +| `colors` | `ColorPalette` | yes | Color palette — the only required token group | +| `typography` | `{ fontFamily?: { base?: string } }` | no | Only `fontFamily.base` is live (see [Typography](#typography)) | +| `borderRadius` | scale object | no | Rounded-corner scale (see [Border Radius](#border-radius)) | +| `shadows` | scale object | no | Box-shadow scale (see [Shadows](#shadows)) | +| `customVars` | `Record` | no | Emitted verbatim as `--: ` | +| `extends` | `string` | no | Name of a theme to inherit from | + +## Theme Modes + +`ThemeMode` is `'light' | 'dark' | 'auto'` — **there is no `'system'` member**; the OS-following +mode is spelled `'auto'`. The vocabulary is also exported as a runtime tuple: + +```ts +import { THEME_MODES, type ThemeMode } from '@object-ui/types'; + +const mode: ThemeMode = 'auto'; +console.log(mode, THEME_MODES); // auto ['auto', 'light', 'dark'] ``` -## Border Radius +With `'auto'`, `ThemeProvider` resolves the effective mode from `prefers-color-scheme` and +re-resolves live when the OS preference changes. The resolved mode is applied as a `light` / +`dark` class on the target element, so Tailwind `dark:` variants respond to it. -```plaintext -interface BorderRadius { - sm?: string; - default?: string; - md?: string; - lg?: string; - xl?: string; -} +A theme document carries a **single `colors` map**, not per-mode palettes: the same variables are +injected in both modes. For a palette that differs between light and dark, author two theme +documents — typically a dark variant that `extends` the light one (see +[Theme Inheritance](#theme-inheritance)) — and switch between them with `setTheme`. + +## Color Palette + +`colors.primary` is required; every other key is optional. Keys are emitted as the Shadcn CSS +variables ObjectUI components already consume: + +| Key | Required | CSS variable | +|-----|----------|--------------| +| `primary` | yes | `--primary` | +| `secondary` | | `--secondary` | +| `accent` | | `--accent` | +| `success` | | `--success` | +| `warning` | | `--warning` | +| `error` | | `--destructive` | +| `info` | | `--info` | +| `background` | | `--background` | +| `surface` | | `--card` | +| `text` | | `--foreground` | +| `textSecondary` | | `--muted-foreground` | +| `border` | | `--border` | +| `disabled` | | `--muted` | +| `primaryLight` | | `--primary-light` | +| `primaryDark` | | `--primary-dark` | +| `secondaryLight` | | `--secondary-light` | +| `secondaryDark` | | `--secondary-dark` | + +Hex values (`#3b82f6`) are converted to the `H S% L%` channel format Shadcn variables expect; any +other CSS color syntax (`rgb(...)`, `hsl(...)`, `oklch(...)`) passes through unchanged. + +```ts +import type { ColorPalette } from '@object-ui/types'; + +const colors: ColorPalette = { + primary: '#3b82f6', + secondary: '#64748b', + accent: '#8b5cf6', + error: '#ef4444', + background: '#ffffff', + surface: '#f8fafc', + text: '#0f172a', + textSecondary: '#64748b', + border: '#e2e8f0', +}; ``` -## Complete Theme Example +## Typography -```plaintext -const professionalTheme: ThemeSchema = { - type: 'theme', - mode: 'system', - - themes: [ - { - name: 'professional', - label: 'Professional', - - light: { - primary: '#3b82f6', - secondary: '#64748b', - accent: '#8b5cf6', - background: '#ffffff', - foreground: '#0f172a', - muted: '#f1f5f9', - mutedForeground: '#64748b', - card: '#ffffff', - cardForeground: '#0f172a', - popover: '#ffffff', - popoverForeground: '#0f172a', - border: '#e2e8f0', - input: '#e2e8f0', - ring: '#3b82f6', - success: '#10b981', - warning: '#f59e0b', - destructive: '#ef4444', - info: '#3b82f6' - }, - - dark: { - primary: '#60a5fa', - secondary: '#94a3b8', - accent: '#a78bfa', - background: '#0f172a', - foreground: '#f1f5f9', - muted: '#1e293b', - mutedForeground: '#94a3b8', - card: '#1e293b', - cardForeground: '#f1f5f9', - popover: '#1e293b', - popoverForeground: '#f1f5f9', - border: '#334155', - input: '#334155', - ring: '#60a5fa', - success: '#34d399', - warning: '#fbbf24', - destructive: '#f87171', - info: '#60a5fa' - }, - - typography: { - fontSans: ['Inter', 'system-ui', 'sans-serif'], - fontSize: 16, - lineHeight: 1.5, - headingWeight: 600, - bodyWeight: 400 - }, - - radius: { - sm: '0.25rem', - default: '0.5rem', - md: '0.75rem', - lg: '1rem', - xl: '1.5rem' - }, - - cssVariables: { - '--header-height': '4rem', - '--sidebar-width': '16rem' - } - } - ], - - activeTheme: 'professional', - allowSwitching: true, - persistPreference: true, - storageKey: 'app-theme' +`typography.fontFamily.base` is the only live typography key — it is emitted as `--font-sans`: + +```ts +import type { Theme } from '@object-ui/types'; + +const branded: Theme = { + name: 'branded', + label: 'Branded', + colors: { primary: '#3b82f6' }, + typography: { + fontFamily: { base: 'Inter, system-ui, sans-serif' }, + }, + customVars: { + 'font-size-base': '16px', + 'line-height-base': '1.5', + }, }; ``` -## Theme Switcher +The former typography scales (`fontSize`, `fontWeight`, `lineHeight`, `letterSpacing`, +`fontFamily.heading`, `fontFamily.mono`) were retired upstream (objectstack#5021): a +theme declaring them is **refused, not accepted-and-stripped**. `customVars` +is the declared replacement — an entry is emitted verbatim, so +`customVars: { 'font-size-lg': '1.125rem' }` puts the same `--font-size-lg` on the document that +the retired scale used to. The retired keys are typed `never`, which makes the refusal a +compile-time error: + +```ts +import type { Theme } from '@object-ui/types'; + +const legacy: Theme = { + name: 'legacy', + label: 'Legacy', + colors: { primary: '#3b82f6' }, + typography: { + // @ts-expect-error -- `fontSize` was retired (objectstack#5021); author `customVars` instead + fontSize: 16, + }, +}; +``` -Use `ThemeSwitcherSchema` to add a theme switcher UI: +## Border Radius -```plaintext -import type { ThemeSwitcherSchema } from '@object-ui/types'; +The key is `borderRadius` (not `radius`), and the middle step is `base` (not `default`): + +```ts +import type { Theme } from '@object-ui/types'; + +const rounded: Theme = { + name: 'rounded', + label: 'Rounded', + colors: { primary: '#3b82f6' }, + borderRadius: { + sm: '0.25rem', + base: '0.5rem', + md: '0.75rem', + lg: '1rem', + xl: '1.5rem', + }, +}; +``` -const switcher: ThemeSwitcherSchema = { - type: 'theme-switcher', - variant: 'dropdown', - showMode: true, // Show light/dark mode toggle - showThemes: true, // Show theme selector - lightIcon: 'Sun', - darkIcon: 'Moon' +| Key | CSS variable | +|-----|--------------| +| `none` | `--radius-none` | +| `sm` | `--radius-sm` | +| `base` | `--radius` | +| `md` | `--radius-md` | +| `lg` | `--radius-lg` | +| `xl` | `--radius-xl` | +| `2xl` | `--radius-2xl` | +| `full` | `--radius-full` | + +## Shadows + +The `shadows` scale has the same shape, with `inner` in place of `full`: + +| Key | CSS variable | +|-----|--------------| +| `none` | `--shadow-none` | +| `sm` | `--shadow-sm` | +| `base` | `--shadow` | +| `md` | `--shadow-md` | +| `lg` | `--shadow-lg` | +| `xl` | `--shadow-xl` | +| `2xl` | `--shadow-2xl` | +| `inner` | `--shadow-inner` | + +## Custom Variables + +`customVars` entries are emitted verbatim onto the target element as `--: ` (a leading +`--` is added when the key does not carry one). This is the declared door for any token the schema +does not model — z-index steps, animation durations, layout dimensions: + +```ts +import type { Theme } from '@object-ui/types'; + +const dashboard: Theme = { + name: 'dashboard', + label: 'Dashboard', + colors: { primary: '#3b82f6' }, + customVars: { + 'header-height': '4rem', + 'sidebar-width': '16rem', + '--z-modal': '1400', + }, }; ``` -### Switcher Variants +## Theme Inheritance + +A theme can extend another by `name`. On resolution the chain is merged deep for `colors`, +`typography`, `borderRadius`, `shadows` and `customVars` — the child overrides key by key and +inherits the rest. Cycles are detected and stop the walk. -- **`dropdown`** - Dropdown menu with theme options -- **`toggle`** - Simple light/dark toggle button -- **`buttons`** - Button group with all options +```ts +import type { Theme } from '@object-ui/types'; + +const acmeLight: Theme = { + name: 'acme-light', + label: 'Acme', + colors: { primary: '#3b82f6', background: '#ffffff', text: '#0f172a' }, + borderRadius: { base: '0.5rem' }, +}; + +const acmeDark: Theme = { + name: 'acme-dark', + label: 'Acme Dark', + extends: 'acme-light', + colors: { primary: '#60a5fa', background: '#0f172a', text: '#f1f5f9' }, +}; +``` -## Theme Preview +Register both on the provider: `acme-dark` resolves against `acme-light`, inheriting the +`borderRadius` scale while its `colors` override key by key. -Use `ThemePreviewSchema` to preview themes: +The engine functions are exported for direct use: -```plaintext -import type { ThemePreviewSchema } from '@object-ui/types'; +```ts +import { generateThemeVars, resolveMode } from '@object-ui/core'; +import type { Theme } from '@object-ui/types'; -const preview: ThemePreviewSchema = { - type: 'theme-preview', - theme: professionalTheme.themes[0], - mode: 'light', - showColors: true, - showTypography: true, - showComponents: true +const probe: Theme = { + name: 'probe', + label: 'Probe', + colors: { primary: '#3b82f6' }, }; + +const vars = generateThemeVars(probe); // { '--primary': '217 91% 60%' } +const effective = resolveMode('auto'); // 'light' | 'dark', from prefers-color-scheme +console.log(vars, effective); ``` -## Runtime Validation +## Validation -```plaintext -import { ThemeSchema } from '@object-ui/types/zod'; +Theme documents are validated at the **type level**: retired keys are `never`-typed tombstones, so +an invalid document fails to compile rather than being silently stripped at runtime. There is no +runtime validator for theme documents — the zod schemas `@objectstack/spec` used to publish +(`ThemeDefinitionSchema` and its token sub-schemas) were retired with its theme module, and +`@object-ui/types/zod` does not export a replacement. -const result = ThemeSchema.safeParse(myTheme); +For the runtime check that matters to theming — accessibility — the engine ships WCAG helpers: -if (result.success) { - console.log('Valid theme configuration'); -} else { - console.error('Validation errors:', result.error); -} +```ts +import { contrastRatio, meetsContrastLevel } from '@object-ui/core'; + +const ratio = contrastRatio('#0f172a', '#ffffff'); // ≈ 14.9 +const readable = meetsContrastLevel('#0f172a', '#ffffff', 'AA'); // true +console.log(ratio, readable); ``` -## Use Cases +## Complete Theme Example + +```ts +import type { Theme } from '@object-ui/types'; + +const professional: Theme = { + name: 'professional', + label: 'Professional', + description: 'Default corporate look', + mode: 'auto', + + colors: { + primary: '#3b82f6', + secondary: '#64748b', + accent: '#8b5cf6', + success: '#10b981', + warning: '#f59e0b', + error: '#ef4444', + info: '#0ea5e9', + background: '#ffffff', + surface: '#f8fafc', + text: '#0f172a', + textSecondary: '#64748b', + border: '#e2e8f0', + }, + + typography: { + fontFamily: { base: 'Inter, system-ui, sans-serif' }, + }, + + borderRadius: { + sm: '0.25rem', + base: '0.5rem', + md: '0.75rem', + lg: '1rem', + }, -ThemeSchema is perfect for: + shadows: { + sm: '0 1px 2px 0 rgb(0 0 0 / 0.05)', + base: '0 1px 3px 0 rgb(0 0 0 / 0.1)', + lg: '0 10px 15px -3px rgb(0 0 0 / 0.1)', + }, -- **Brand consistency** - Maintain consistent visual identity across applications -- **White-labeling** - Enable multi-tenant applications with custom branding -- **Accessibility** - Provide light/dark modes for user preference -- **Design systems** - Implement comprehensive design tokens -- **A/B testing** - Test different color schemes and typography + customVars: { + 'header-height': '4rem', + 'sidebar-width': '16rem', + }, +}; +``` ## Best Practices -1. **Use semantic colors** - Stick to the semantic color tokens for consistency -2. **Test both modes** - Always test light and dark modes -3. **Maintain contrast** - Ensure sufficient color contrast for accessibility -4. **Limit custom themes** - Offer 2-3 well-designed themes max -5. **Respect system preferences** - Use `mode: 'system'` by default -6. **Persist preferences** - Enable `persistPreference` for better UX +1. **Use the semantic keys** — map brand colors onto `primary` / `accent` / `error` rather than + inventing custom variables for tokens the palette already models. +2. **Test both modes** — an `'auto'` theme renders under both the `light` and `dark` classes. +3. **Maintain contrast** — check WCAG pairs with `meetsContrastLevel` from `@object-ui/core`. +4. **Default to `'auto'`** — respect the OS preference; it is the provider's default mode. +5. **Persist on the provider** — user preference is `ThemeProvider`'s `persist` / `storageKey`, + not a key on the theme document. +6. **Share tokens with `extends`** — author variants as small overrides of a base theme. ## Related - [App Schema](/docs/core/app-schema) - Application configuration +- [Schema Overview](/docs/guide/schema-overview) - Where theming sits among the schema families - [CSS Variables](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties) - MDN documentation - [Tailwind Theming](https://tailwindcss.com/docs/theme) - Tailwind CSS theming guide diff --git a/scripts/check-doc-component-types.mjs b/scripts/check-doc-component-types.mjs index 5f8669a35..775591654 100644 --- a/scripts/check-doc-component-types.mjs +++ b/scripts/check-doc-component-types.mjs @@ -339,17 +339,6 @@ const DOC_TYPE_EXEMPTIONS = { 'Deliberate placeholder in the "register your own component" walkthrough — the page teaches ' + 'the reader to register this key, so it is unregistered here by design.', }, - 'content/docs/core/theme-schema.mdx': { - theme: - 'RETIRED discriminant, kept exempt only until this page is rewritten. `ThemeComponentSchema` ' + - '(`type: \'theme\'`) was removed from packages/types in objectui#5489 under the maintainer ' + - 'ruling of 2026-08-21 — no renderer ever implemented it, so the literal named nothing even ' + - 'before the removal. This page still teaches it, along with five falsehoods that predate the ' + - 'retirement; the rewrite around the RETAINED theme document is objectui#5648, and DELETING ' + - 'this entry is part of it.', - 'theme-preview': 'ThemePreviewSchema discriminant — packages/types/src/theme.ts:167.', - 'theme-switcher': 'ThemeSwitcherSchema discriminant — packages/types/src/theme.ts:145.', - }, 'content/docs/fields/object.mdx': { array: 'JSON Schema property type inside a field\'s `schema.properties`, not a node type.', string: 'JSON Schema property type inside a field\'s `schema.properties`, not a node type.',