diff --git a/packages/react-ui/src/components/ThemeProvider/AGENTS.md b/packages/react-ui/src/components/ThemeProvider/AGENTS.md index 071f26ae3..f05866ed2 100644 --- a/packages/react-ui/src/components/ThemeProvider/AGENTS.md +++ b/packages/react-ui/src/components/ThemeProvider/AGENTS.md @@ -2,14 +2,14 @@ ## File Map -| File | Purpose | -| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ThemeProvider.tsx` | React component, `ThemeContext`, `InternalContext` (nesting detection), `useTheme` hook. Injects `--openui-*` vars into `` via `useInsertionEffect`. | -| `types.ts` | TypeScript interfaces: `Theme`, `ColorTheme`, `LayoutTheme`, `TypographyTheme`, `EffectTheme`, `ChartColorPalette`. | -| `defaultTheme.ts` | Builds `defaultLightTheme` / `defaultDarkTheme` (frozen) from the swatch system. Contains `createColorTheme()` and all layout/typography/shadow defaults. | -| `swatches.ts` | 18 oklch color families x 14 shades. Exports `swatch()`, `withAlpha()`, `swatchToken()`, `swatchTokens`. | -| `utils.ts` | Exports `camelToKebab`, `themeToCssVars`, `createTheme` (dev-mode typo detection with Levenshtein distance suggestions). | -| `index.ts` | Barrel re-exports from all the above files. | +| File | Purpose | +| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ThemeProvider.tsx` | React component, `ThemeContext`, `InternalContext` (nesting detection), `useTheme` hook. Injects `--openui-*` vars into `` via `useInsertionEffect`. | +| `types.ts` | TypeScript interfaces: `Theme`, `ColorTheme`, `LayoutTheme`, `TypographyTheme`, `EffectTheme`, `ChartColorPalette`. | +| `defaultTheme.ts` | Builds `defaultLightTheme` / `defaultDarkTheme` (frozen) from the swatch system. Contains `createColorTheme()` and all layout/typography/shadow defaults. | +| `swatches.ts` | 18 oklch color families x 14 shades. Exports `swatch()`, `withAlpha()`, `swatchToken()`, `swatchTokens`. | +| `utils.ts` | Exports `camelToKebab`, `themeToCssVars`, `createTheme` (dev-mode typo detection with Levenshtein distance suggestions), and `KNOWN_THEME_KEYS` / `CHART_PALETTE_KEYS` (shared validator allow-list). | +| `index.ts` | Barrel re-exports from all the above files. | ## Architecture @@ -44,6 +44,8 @@ Only **2 files** need editing — the runtime walker (`themeToCssVars`) and the Run `pnpm build` (or `pnpm generate:css-utils`) to regenerate `cssUtils.scss` with the new token and its fallback. +**Exception — chart palette keys** (`string[]`, no defaults): the validator allow-list is `Object.keys(defaultLightTheme)` + `CHART_PALETTE_KEYS` (`utils.ts`). A new key on `ChartColorPalette` must also be added to `CHART_PALETTE_KEY_MAP` in `utils.ts` — the `satisfies Record` guard turns any drift into a compile error. Do **not** give palettes defaults in `defaultTheme.ts`: a default would take precedence over the per-chart `theme` prop fallback in `useChartPalette`. + ### Naming Convention ``` diff --git a/packages/react-ui/src/components/ThemeProvider/ThemeProvider.tsx b/packages/react-ui/src/components/ThemeProvider/ThemeProvider.tsx index b377550cf..cbb4dffb1 100644 --- a/packages/react-ui/src/components/ThemeProvider/ThemeProvider.tsx +++ b/packages/react-ui/src/components/ThemeProvider/ThemeProvider.tsx @@ -1,7 +1,7 @@ import React, { createContext, useContext, useId, useInsertionEffect, useMemo } from "react"; import { defaultDarkTheme, defaultLightTheme } from "./defaultTheme"; import { Theme, ThemeMode } from "./types"; -import { themeToCssVars } from "./utils"; +import { KNOWN_THEME_KEYS, themeToCssVars } from "./utils"; /** * Props for the {@link ThemeProvider} component. @@ -109,8 +109,6 @@ function cssSafeId(id: string): string { return id.replace(/[^a-zA-Z0-9-_]/g, ""); } -const _knownThemeKeys = new Set(Object.keys(defaultLightTheme)); - function validateThemeObject(themeObj: Theme, propName: string) { for (const [key, value] of Object.entries(themeObj)) { if (value !== undefined && typeof value !== "string" && !Array.isArray(value)) { @@ -119,7 +117,7 @@ function validateThemeObject(themeObj: Theme, propName: string) { `[OpenUI] ${propName} key "${key}" has a non-string value (${typeof value}). All theme values should be strings.`, ); } - if (!_knownThemeKeys.has(key)) { + if (!KNOWN_THEME_KEYS.has(key)) { warnOnce( `unknown-key:${propName}:${key}`, `[OpenUI] ${propName} contains unknown key "${key}". It will be ignored. Use createTheme() for typo detection with suggestions.`, diff --git a/packages/react-ui/src/components/ThemeProvider/__tests__/ThemeProvider.test.tsx b/packages/react-ui/src/components/ThemeProvider/__tests__/ThemeProvider.test.tsx new file mode 100644 index 000000000..422151e2f --- /dev/null +++ b/packages/react-ui/src/components/ThemeProvider/__tests__/ThemeProvider.test.tsx @@ -0,0 +1,138 @@ +import { renderToString } from "react-dom/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useChartPalette } from "../../Charts/utils/PalletUtils"; +import { defaultDarkTheme, defaultLightTheme } from "../defaultTheme"; +import { ThemeProvider, useTheme } from "../ThemeProvider"; +import type { ChartColorPalette, Theme } from "../types"; +import { CHART_PALETTE_KEYS, createTheme, KNOWN_THEME_KEYS } from "../utils"; + +// Regression tests for the types-vs-runtime drift where every `*ChartPalette` +// key (valid per the Theme type) was rejected as "unknown key" by both +// validators because the allow-list only contained Object.keys(defaultLightTheme). +// See https://github.com/thesysdev/openui/issues/714 + +// `Required` forces this literal to cover every declared palette key, so a new +// key added to ChartColorPalette fails compilation here until it is tested. +const allChartPalettes: Required = { + defaultChartPalette: ["#101010", "#202020", "#303030"], + barChartPalette: ["#111111", "#222222", "#333333"], + lineChartPalette: ["#414141", "#525252", "#636363"], + areaChartPalette: ["#747474", "#858585", "#969696"], + pieChartPalette: ["#a7a7a7", "#b8b8b8", "#c9c9c9"], + radarChartPalette: ["#dadada", "#ebebeb", "#fcfcfc"], + radialChartPalette: ["#0d0d0d", "#1e1e1e", "#2f2f2f"], + horizontalBarChartPalette: ["#404040", "#515151", "#626262"], +}; + +let warnSpy: ReturnType; + +beforeEach(() => { + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); +}); + +afterEach(() => { + warnSpy.mockRestore(); +}); + +describe("theme validator allow-list", () => { + it("covers every declared chart palette key", () => { + const missing = CHART_PALETTE_KEYS.filter((key) => !KNOWN_THEME_KEYS.has(key)); + expect(missing).toEqual([]); + }); + + it("covers every key of the default themes", () => { + const missingLight = Object.keys(defaultLightTheme).filter((k) => !KNOWN_THEME_KEYS.has(k)); + const missingDark = Object.keys(defaultDarkTheme).filter((k) => !KNOWN_THEME_KEYS.has(k)); + expect(missingLight).toEqual([]); + expect(missingDark).toEqual([]); + }); +}); + +describe("createTheme", () => { + it("does not warn for any declared chart palette key", () => { + createTheme({ ...allChartPalettes }); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it("still warns with a suggestion for a genuine typo", () => { + createTheme({ defaultChartPalete: ["#ffffff"] } as unknown as Theme); + expect(warnSpy).toHaveBeenCalledWith( + '[OpenUI] Unknown theme key "defaultChartPalete". Did you mean "defaultChartPalette"?', + ); + }); +}); + +describe("ThemeProvider prop validation", () => { + it("does not warn for chart palette keys on lightTheme or darkTheme", () => { + renderToString( + + + , + ); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('still warns "unknown key" for keys not on the Theme type', () => { + renderToString( + + + , + ); + expect(warnSpy).toHaveBeenCalledWith( + '[OpenUI] lightTheme contains unknown key "bogusThemeKey". It will be ignored. Use createTheme() for typo detection with suggestions.', + ); + }); +}); + +describe("chart palette flow (theme -> useTheme -> useChartPalette)", () => { + it("delivers user palettes to charts and falls back to defaultChartPalette", () => { + const defaultPalette = ["#57c8d6", "#ff6a2b", "#aabbcc"]; + const barPalette = ["#111111", "#222222", "#333333"]; + + let themeFromContext: Theme | undefined; + let barColors: string[] = []; + let lineColors: string[] = []; + + const Probe = () => { + const { theme } = useTheme(); + themeFromContext = theme; + // Exactly what BarChart does. + barColors = useChartPalette({ + chartThemeName: "ocean", + themePaletteName: "barChartPalette", + dataLength: 2, + }); + // lineChartPalette is not set -> must fall back to defaultChartPalette. + lineColors = useChartPalette({ + chartThemeName: "ocean", + themePaletteName: "lineChartPalette", + dataLength: 2, + }); + return null; + }; + + renderToString( + + + , + ); + + expect(themeFromContext?.defaultChartPalette).toEqual(defaultPalette); + expect(themeFromContext?.barChartPalette).toEqual(barPalette); + expect(barColors.length).toBe(2); + expect(barColors.every((color) => barPalette.includes(color))).toBe(true); + expect(lineColors.length).toBe(2); + expect(lineColors.every((color) => defaultPalette.includes(color))).toBe(true); + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react-ui/src/components/ThemeProvider/utils.ts b/packages/react-ui/src/components/ThemeProvider/utils.ts index 4dd47ca83..94f7af7a1 100644 --- a/packages/react-ui/src/components/ThemeProvider/utils.ts +++ b/packages/react-ui/src/components/ThemeProvider/utils.ts @@ -1,5 +1,5 @@ import { defaultLightTheme } from "./defaultTheme"; -import { Theme } from "./types"; +import { ChartColorPalette, Theme } from "./types"; /** * Convert a camel case string to a kebab case string. @@ -27,6 +27,36 @@ export function themeToCssVars(theme: Record, prefix = "openui" .join("\n "); } +// Chart palettes are declared on the Theme type but intentionally have no +// entries in the default themes (a default palette would override the +// per-chart `theme` prop fallback in useChartPalette). The validators' +// allow-list is derived from Object.keys(defaultLightTheme), so these keys +// must be added explicitly. The `satisfies` map makes any drift between +// ChartColorPalette and this list a compile error. +const CHART_PALETTE_KEY_MAP = { + defaultChartPalette: true, + barChartPalette: true, + lineChartPalette: true, + areaChartPalette: true, + pieChartPalette: true, + radarChartPalette: true, + radialChartPalette: true, + horizontalBarChartPalette: true, +} as const satisfies Record; + +export const CHART_PALETTE_KEYS = Object.keys(CHART_PALETTE_KEY_MAP) as (keyof ChartColorPalette)[]; + +/** + * Every theme key accepted at runtime: all keys present in the default themes + * plus the chart palette keys that exist only on the type. Shared by + * `createTheme()` and the ThemeProvider prop validator so the two allow-lists + * cannot drift apart. + */ +export const KNOWN_THEME_KEYS: ReadonlySet = new Set([ + ...Object.keys(defaultLightTheme), + ...CHART_PALETTE_KEYS, +]); + function levenshteinDistance(a: string, b: string): number { const m = a.length; const n = b.length; @@ -67,14 +97,13 @@ const _warnedKeys = new Set(); */ export function createTheme(theme: Theme): Theme { if (typeof process !== "undefined" && process.env?.["NODE_ENV"] !== "production") { - const knownKeys = Object.keys(defaultLightTheme); for (const key of Object.keys(theme)) { - if (knownKeys.includes(key) || _warnedKeys.has(key)) continue; + if (KNOWN_THEME_KEYS.has(key) || _warnedKeys.has(key)) continue; _warnedKeys.add(key); let suggestion = ""; let bestDist = Infinity; - for (const known of knownKeys) { + for (const known of KNOWN_THEME_KEYS) { const dist = levenshteinDistance(key, known); if (dist < bestDist) { bestDist = dist; diff --git a/packages/react-ui/tsconfig.test.json b/packages/react-ui/tsconfig.test.json new file mode 100644 index 000000000..60b3001d5 --- /dev/null +++ b/packages/react-ui/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules"] +}