@cueplusplus/ui 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +262 -0
  2. package/README.md +49 -0
  3. package/dist/chat/message-list.js +2 -1
  4. package/dist/configurator/_export.d.ts +1 -1
  5. package/dist/configurator/_export.js +53 -13
  6. package/dist/configurator/_overrides.d.ts +25 -6
  7. package/dist/configurator/_overrides.js +30 -17
  8. package/dist/configurator/configurator.js +8 -3
  9. package/dist/configurator/panel-sections.js +43 -13
  10. package/dist/elements/command-palette.js +1 -1
  11. package/dist/elements/flow-graph.js +2 -2
  12. package/dist/elements/markdown.js +1 -1
  13. package/dist/elements/surfaces.js +4 -3
  14. package/dist/index.d.ts +5 -2
  15. package/dist/index.js +2 -1
  16. package/dist/midi/piano-keyboard.js +5 -1
  17. package/dist/primitives/chip.d.ts +1 -1
  18. package/dist/styles.css +23 -1
  19. package/dist/system/density.d.ts +17 -8
  20. package/dist/system/density.js +39 -16
  21. package/dist/system/index.d.ts +5 -2
  22. package/dist/system/index.js +2 -1
  23. package/dist/system/overrides.d.ts +43 -0
  24. package/dist/system/overrides.js +238 -0
  25. package/dist/system/portal.d.ts +4 -2
  26. package/dist/system/portal.js +34 -3
  27. package/dist/system/prepaint.d.ts +58 -7
  28. package/dist/system/prepaint.js +72 -20
  29. package/dist/system/theme-provider.d.ts +133 -8
  30. package/dist/system/theme-provider.js +203 -72
  31. package/dist/system/theme-registry.d.ts +53 -0
  32. package/dist/system/theme-registry.js +66 -0
  33. package/dist/system/use-density.d.ts +13 -5
  34. package/dist/system/use-density.js +142 -13
  35. package/dist/system/use-theme.d.ts +5 -3
  36. package/dist/system/use-theme.js +5 -3
  37. package/dist/system/vocabulary.d.ts +15 -0
  38. package/dist/system/vocabulary.js +111 -0
  39. package/dist/theming/contrast.d.ts +2 -122
  40. package/dist/theming/contrast.js +2 -194
  41. package/dist/theming/create-theme.d.ts +37 -11
  42. package/dist/theming/create-theme.js +54 -17
  43. package/dist/theming/index.d.ts +3 -4
  44. package/dist/theming/index.js +3 -4
  45. package/dist/theming/serialize.d.ts +24 -11
  46. package/dist/theming/serialize.js +16 -18
  47. package/manifest/components/colors-section.json +2 -3
  48. package/manifest/components/cue-portal-frame.json +1 -1
  49. package/manifest/components/density.json +1 -1
  50. package/manifest/components/export-dialog.json +0 -3
  51. package/manifest/components/preset-section.json +2 -3
  52. package/manifest/components/shape-section.json +2 -3
  53. package/manifest/components/theme-configurator.json +0 -3
  54. package/manifest/components/theme-provider.json +24 -9
  55. package/manifest/components/token-editor.json +0 -3
  56. package/manifest/manifest.json +145 -24
  57. package/manifest/tokens.json +121 -11
  58. package/package.json +15 -6
  59. package/dist/theming/_presets.d.ts +0 -11
  60. package/dist/theming/_presets.js +0 -678
@@ -1,5 +1,6 @@
1
+ import { TokenOverrides } from "./overrides.js";
1
2
  import * as React from "react";
2
- import { Density, FontName, Mode, ThemeName } from "@cueplusplus/tokens";
3
+ import { Density, FontName, Mode, ThemeManifest, ThemeName } from "@cueplusplus/theme-base";
3
4
  //#region src/system/theme-provider.d.ts
4
5
  /** A mode that has been resolved to an actual palette — `"system"` never survives this far. */
5
6
  type ResolvedMode = "dark" | "light";
@@ -7,6 +8,12 @@ type ResolvedMode = "dark" | "light";
7
8
  interface ThemeContextValue {
8
9
  /** Active theme preset. */
9
10
  theme: ThemeName;
11
+ /**
12
+ * The active theme's manifest, or `null` when the active name is not
13
+ * registered — which is every name in an app that passes no `themes`, and the
14
+ * one a `setTheme` for an unknown preset leaves behind.
15
+ */
16
+ manifest: ThemeManifest | null;
10
17
  /** Density the *provider* holds — a nested `<Density>` island does not change it. */
11
18
  density: Density;
12
19
  /** Mode as chosen, including the literal `"system"`. */
@@ -32,15 +39,66 @@ interface FontFamilies {
32
39
  mono?: string;
33
40
  }
34
41
  interface ThemeProviderProps {
35
- /** Initial theme preset. Defaults to `"cue"`. Changing it after mount adopts the new value. */
42
+ /**
43
+ * The registry: an ordered array of theme manifests, the first of which is
44
+ * the default theme.
45
+ *
46
+ * A prop rather than a side effect, so the server render and the client
47
+ * render are handed the same array and nothing depends on which module an app
48
+ * happened to import first. Each entry is a `manifest.json` from a
49
+ * `@cueplusplus/theme-<name>` package; this library never discovers a theme by
50
+ * itself, because which themes a product ships is an application decision.
51
+ *
52
+ * Optional for one release, defaulting to `[]` with a development warning. An
53
+ * app that registers nothing gets the base axes and no registry: any
54
+ * well-formed name is accepted as a theme (so a visitor's stored preference
55
+ * still restores on first paint), density and font validate against the base
56
+ * ladder and pairings, and `useTheme().manifest` is `null`. What it *paints*
57
+ * is a separate question with a separate answer — whichever `[data-theme]`
58
+ * blocks its stylesheets declare, which is the blank base only when none of
59
+ * them declares the stamped name.
60
+ *
61
+ * A nested provider that passes none inherits the ambient registry.
62
+ *
63
+ * One disclosure to be aware of: {@link prepaintScript} inlines the
64
+ * registered **names** — and each theme's `supportsLight` flag, and the names
65
+ * of any rung or pairing it adds — into the blocking script in every page's
66
+ * HTML, because first paint has to judge a stored preference before any
67
+ * module loads. No colours, no geometry, no package names, and only what an
68
+ * app passes here. But an app that registers
69
+ * a per-customer theme is publishing that customer's name to every visitor
70
+ * who views source, so register per-customer themes per response rather than
71
+ * globally.
72
+ */
73
+ themes?: readonly ThemeManifest[];
74
+ /**
75
+ * Initial theme preset. Defaults to the first registered manifest's name, or
76
+ * `"cue"` with nothing registered. Changing it after mount adopts the new value.
77
+ */
36
78
  theme?: ThemeName;
37
- /** Initial density level. Defaults to `"compact"`. */
79
+ /**
80
+ * Initial density level.
81
+ *
82
+ * Defaults to the rung the active theme's manifest names in
83
+ * `densities.default`, and to `"compact"` when it names none — spec §5's
84
+ * rule, and the same one `resolve()` applies when no rung is wanted, so a
85
+ * theme opens on the rung it prefers whether it is read from JS or painted
86
+ * by its own stylesheet. Naming a rung here overrides that for every theme:
87
+ * a consumer who named one named it deliberately.
88
+ *
89
+ * Changing it after mount adopts the new value; dropping it does not — a
90
+ * parent that stops naming a rung is not asking to move back to the theme's.
91
+ */
38
92
  density?: Density;
39
93
  /** Initial mode. Defaults to `"dark"`; `"system"` tracks `prefers-color-scheme`. */
40
94
  mode?: Mode;
41
95
  /**
42
- * Initial font pairing. Defaults to `"system"` — the platform's own faces,
43
- * nothing downloaded, and the theme keeps whatever monospace it authored.
96
+ * Initial font pairing.
97
+ *
98
+ * Defaults to the pairing the active theme's manifest names in
99
+ * `fontPairings.default` — §5's rule, the density prop's rule one axis over
100
+ * — and to `"system"` when it names none: the platform's own faces, nothing
101
+ * downloaded, and the theme keeps whatever monospace it authored.
44
102
  *
45
103
  * A pairing that needs delivering is a set of *names*: this library ships no
46
104
  * font files, and a pairing nobody delivers falls through its stack to the
@@ -58,6 +116,66 @@ interface ThemeProviderProps {
58
116
  fontScale?: number;
59
117
  /** Optional app-owned font stacks, published as inline CUE font custom properties. */
60
118
  fontFamilies?: FontFamilies;
119
+ /**
120
+ * Typed token edits layered over the active theme: colours per mode, geometry
121
+ * per rung, and the three font stacks.
122
+ *
123
+ * For the band of edits that sit below "publish a theme package": one accent
124
+ * for a tenant, a stack the app already loads, a rung with two more pixels in
125
+ * a touch build. Anything larger belongs in a `@cueplusplus/theme-<name>`
126
+ * package, where a build measures it; anything smaller than this is a
127
+ * `!important` in a stray stylesheet, which outranks the token layer
128
+ * everywhere at once.
129
+ *
130
+ * **Colours and fonts are inline on this element**, so they inherit down the
131
+ * subtree, beat every stylesheet without `!important`, and are re-stamped
132
+ * onto portal containers — which mount on `<body>` and inherit nothing from
133
+ * here. They do not leak *out* of this provider: a nested provider's colour
134
+ * edit is scoped to its own subtree, and to portals opened from inside it.
135
+ *
136
+ * **Densities are one document-scoped `<style>` element**, because a rung is
137
+ * selected by attribute rather than inherited, and because
138
+ * `useControlHeight()`'s measuring probe hangs off `document.body`, where
139
+ * a subtree-scoped rule would not reach it — leaving the measured height and
140
+ * the painted control disagreeing by exactly the override. The consequence is
141
+ * worth knowing before you nest one: a nested provider's `densities` edit
142
+ * reaches the whole page, exactly as a theme's own rung rules do. Colours and
143
+ * fonts in the same object do not.
144
+ *
145
+ * And it cuts the other way as well, which is the half that surprises: two
146
+ * providers editing the same rung produce two document-scoped rules of equal
147
+ * specificity, so the **later** one in document order wins everywhere — and
148
+ * the later one is the outer provider's, because a nested provider renders
149
+ * inside it. A nested `densities` edit does not merely leak out; an ancestor
150
+ * that edits the same rung overrules it *inside the nested subtree too*. If
151
+ * an island needs geometry of its own, it needs a rung of its own — a name no
152
+ * ancestor is editing — not the same rung with different numbers.
153
+ *
154
+ * Its rules are `[data-density="x"][data-density="x"]` — (0,2,0), tying a
155
+ * theme's own `[data-theme="t"] [data-density="x"]` and winning on source
156
+ * order — so the precedence is: **`overrides`, then the configurator's
157
+ * persisted snapshot, then the theme, then base.**
158
+ *
159
+ * **A font override shadows the pairing for this subtree.** `data-font` is
160
+ * the document's axis and this element deliberately never restates it (see
161
+ * the note by `stamp` below), but `overrides.fonts` writes the resolved
162
+ * `--cue-font-*` properties directly, which is a stronger claim than the
163
+ * attribute and is the point: this is how an app says "this product's face,
164
+ * whatever pairing the visitor picked".
165
+ *
166
+ * In development the resulting palette is measured, and any required contrast
167
+ * pair the edit broke — or made worse — is named on the console.
168
+ *
169
+ * @example
170
+ * <ThemeProvider themes={[cue]} overrides={{
171
+ * colors: { dark: { accent: "#ff8800" }, light: { accent: "#b35900" } },
172
+ * densities: { compact: { "control-md": "1.75rem" } },
173
+ * fonts: { sans: '"Tenant Sans", ui-sans-serif, sans-serif' },
174
+ * }}>
175
+ * <App />
176
+ * </ThemeProvider>
177
+ */
178
+ overrides?: TokenOverrides;
61
179
  /**
62
180
  * localStorage key holding `{ theme, density, font, mode }`. Defaults to `"cue-ui"`.
63
181
  * Only the outermost provider reads or writes it — a nested provider is an
@@ -78,7 +196,8 @@ interface ThemeProviderProps {
78
196
  /**
79
197
  * Root of the theme system: stamps `data-theme`, `data-density` and `data-mode`
80
198
  * so the token layer resolves, puts `data-font` on `<html>`, publishes
81
- * `--cue-font-scale`, and owns the persisted user preference.
199
+ * `--cue-font-scale`, owns the persisted user preference, and publishes the
200
+ * registry the three axis hooks read.
82
201
  *
83
202
  * Rendering: `<div data-cue-root data-theme data-density data-mode style={{ colorScheme, --cue-font-scale }}>`
84
203
  * (or the single child when `asChild`). The `theme`/`density`/`font`/`mode` props are
@@ -100,10 +219,16 @@ interface ThemeProviderProps {
100
219
  * font pairing — `useTheme().font` and `setFont` inside one are the root's.
101
220
  *
102
221
  * @example
103
- * <ThemeProvider theme="terminal" density="ultra-compact" font="plex" mode="system">
222
+ * // `cue` and `terminal` are the `manifest.json` each theme package ships, read
223
+ * // from the `…/manifest.json` subpath of `@cueplusplus/theme-cue` and
224
+ * // `@cueplusplus/theme-terminal`. Written that way round on purpose: a literal
225
+ * // import statement in this comment reads, to every import-graph gate in this
226
+ * // repository, as `ui` depending on a theme package — which is the one thing
227
+ * // it may not do.
228
+ * <ThemeProvider themes={[cue, terminal]} theme="terminal" density="ultra-compact" font="plex" mode="system">
104
229
  * <App />
105
230
  * </ThemeProvider>
106
231
  */
107
- declare function ThemeProvider({ theme: themeProp, density: densityProp, mode: modeProp, font: fontProp, fontScale, fontFamilies, storageKey, persistPreferences, asChild, className, style: styleProp, children }: ThemeProviderProps): React.JSX.Element;
232
+ declare function ThemeProvider({ themes: themesProp, theme: themeProp, density: densityProp, mode: modeProp, font: fontProp, fontScale, fontFamilies, overrides, storageKey, persistPreferences, asChild, className, style: styleProp, children }: ThemeProviderProps): React.JSX.Element;
108
233
  //#endregion
109
234
  export { FontFamilies, ResolvedMode, ThemeContextValue, ThemeProvider, ThemeProviderProps };
@@ -1,19 +1,42 @@
1
1
  "use client";
2
2
  import { cn } from "../lib/cn.js";
3
3
  import { useIsomorphicInsertionEffect, useIsomorphicLayoutEffect } from "./use-isomorphic-layout-effect.js";
4
- import { DEFAULT_DENSITY as DEFAULT_DENSITY$1, DENSITY_LEVELS, DensityContext } from "./density.js";
4
+ import { AmbientThemeContext, DensityContext } from "./density.js";
5
+ import { buildRegistry, vocabulary, warnOnce } from "./vocabulary.js";
6
+ import { OverridesContext, inlineOverrides, overridesCss, reportOverrides } from "./overrides.js";
5
7
  import { DEFAULT_STORAGE_KEY } from "./prepaint.js";
8
+ import { ThemeRegistryContext } from "./theme-registry.js";
6
9
  import * as React from "react";
7
- import { jsx } from "react/jsx-runtime";
8
- import { DEFAULT_FONT, FONTS, THEMES } from "@cueplusplus/tokens";
10
+ import { jsx, jsxs } from "react/jsx-runtime";
11
+ import { MODES, THEME_NAME_PATTERN, validateManifest } from "@cueplusplus/theme-base";
9
12
  //#region src/system/theme-provider.tsx
10
- const MODES$1 = [
11
- "dark",
12
- "light",
13
- "system"
14
- ];
15
- const FONT_NAMES = FONTS;
16
- /** Dark-first portfolio defaults; the same triple bare `:root` in `theme.css` paints. */
13
+ /**
14
+ * The empty registry, at module scope so the default is referentially stable.
15
+ *
16
+ * Every memo below is keyed on `themes`, and a fresh `[]` per render would
17
+ * rebuild the registry, the vocabulary and the context value on every commit of
18
+ * every app that has not registered anything yet — which, for one release, is
19
+ * all of them.
20
+ */
21
+ const EMPTY = [];
22
+ /**
23
+ * Dark-first defaults, and the one thing to know about the first of them: it is
24
+ * a *name*, and since this release nothing in the library paints it.
25
+ *
26
+ * A bare `:root` used to carry `cue`'s palette, because `@cueplusplus/tokens`
27
+ * shipped ten presets and put the default one there. `styles.css` now imports
28
+ * `@cueplusplus/theme-base/base.css` instead, whose `:root` is the blank base —
29
+ * greys and one desaturated accent. So an app that registers nothing and stamps
30
+ * nothing gets `data-theme="cue"` on its provider element and paints the blank
31
+ * base, because no stylesheet on the page declares a `[data-theme="cue"]` block.
32
+ * Colour is decided by CSS, not by this constant.
33
+ *
34
+ * That is deliberate and it is why the name stays `cue`: the alternative — a
35
+ * default of `"base"` or `""` — would change what every app that *does* import
36
+ * `@cueplusplus/theme-cue/theme.css` renders when it passes no `theme` prop, to
37
+ * buy nothing, since a name that matches no imported block already paints the
38
+ * base.
39
+ */
17
40
  const DEFAULT_THEME = "cue";
18
41
  const DEFAULT_MODE = "dark";
19
42
  const DEFAULT_RESOLVED_MODE = "dark";
@@ -51,28 +74,48 @@ function readPreferences(storageKey) {
51
74
  return {};
52
75
  }
53
76
  }
54
- /** Resolve `"system"` the way {@link prepaintScript} resolves it: against the media query. */
55
- function resolveMode(mode) {
56
- if (mode !== "system") return mode;
57
- if (typeof matchMedia !== "function") return DEFAULT_RESOLVED_MODE;
58
- return matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
59
- }
60
77
  /**
61
- * The stamp {@link prepaintScript} put on `<html>`, recomputed from the storage
62
- * it read.
78
+ * The stored quadruple, accepted the way the pre-paint script accepts it:
79
+ * **theme first**, then each axis under the theme resolution actually chose.
80
+ *
81
+ * A rung a theme adds is scoped by the cascade to that theme's `data-theme`, so
82
+ * checking it against the theme that is about to be replaced rejects exactly
83
+ * the pairing the registry exists to allow — and on every commit that reads
84
+ * storage the component's `theme` is still the *prop*, never the theme the
85
+ * visitor stored. The script does `var t=…; var a=A[t]`; so does this, and it
86
+ * is the only place either rule is applied.
87
+ *
88
+ * `mode` is returned as stored, including the literal `"system"`. Resolving it
89
+ * here is what used to put `data-mode="dark"` on `<html>` for one commit under
90
+ * a stored `system`, which the stamp's own comment says must not happen.
63
91
  *
64
- * Not read back off the element, because by the time anything here runs it is
65
- * no longer there — see the `<html>` stamp below. Same key, same validation,
66
- * same fallbacks, so this returns what the script decided rather than a second
67
- * opinion about it.
92
+ * **The fallback is judged too.** It is the app's own default quadruple, which
93
+ * was chosen under the app's own default *theme* — and the theme resolution
94
+ * just chose need not be that one. Handing back `fallback.density` unchecked
95
+ * puts a rung the resolved theme does not declare onto `<html>`: an attribute
96
+ * no block matches, which is the precise failure the registry exists to
97
+ * prevent, arrived at by the code that was meant to prevent it. So each axis
98
+ * falls through the stored value, then the app default, then the base default,
99
+ * which every theme offers by construction.
100
+ *
101
+ * A *refused theme* is the one case here worth a word to the developer. The
102
+ * other three axes have a visible fallback the reader can see and change back;
103
+ * a theme that was registered when the visitor chose it and is not registered
104
+ * now is almost always a registration that was dropped by accident, and it
105
+ * shows up as "my users keep losing their theme" long after the commit that
106
+ * caused it. With an empty registry nothing is refused for being unregistered
107
+ * — theme validation is off — so the only refusal left there is a name that
108
+ * could not be a `data-theme` value at all, and it gets the other sentence:
109
+ * "register it" is not the fix for `"9bad"`.
68
110
  */
69
- function prepaintStamp(storageKey, fallback) {
70
- const stored = readPreferences(storageKey);
111
+ function acceptedPreferences(vocab, stored, fallback) {
112
+ const theme = vocab.acceptsTheme(stored.theme) ? stored.theme : fallback.theme;
113
+ if (stored.theme !== void 0 && stored.theme !== theme) warnOnce(vocab.themes === null ? `stored theme "${stored.theme}" is not a valid theme name; falling back to "${theme}". A theme name must match ${String(THEME_NAME_PATTERN)}.` : `stored theme "${stored.theme}" is not registered; falling back to "${theme}". A visitor who chose it keeps losing it until its manifest is back in ThemeProvider's themes prop.`);
71
114
  return {
72
- theme: typeof stored.theme === "string" && stored.theme.length > 0 ? stored.theme : fallback.theme,
73
- density: isOneOf(DENSITY_LEVELS, stored.density) ? stored.density : fallback.density,
74
- font: isOneOf(FONT_NAMES, stored.font) ? stored.font : fallback.font,
75
- mode: resolveMode(isOneOf(MODES$1, stored.mode) ? stored.mode : fallback.mode)
115
+ theme,
116
+ density: vocab.acceptsDensity(theme, stored.density) ? stored.density : vocab.acceptsDensity(theme, fallback.density) ? fallback.density : vocab.preferredDensity(theme),
117
+ font: vocab.acceptsFont(theme, stored.font) ? stored.font : vocab.acceptsFont(theme, fallback.font) ? fallback.font : vocab.preferredFont(theme),
118
+ mode: isOneOf(MODES, stored.mode) ? stored.mode : fallback.mode
76
119
  };
77
120
  }
78
121
  function writePreferences(storageKey, preferences) {
@@ -84,7 +127,8 @@ function writePreferences(storageKey, preferences) {
84
127
  /**
85
128
  * Root of the theme system: stamps `data-theme`, `data-density` and `data-mode`
86
129
  * so the token layer resolves, puts `data-font` on `<html>`, publishes
87
- * `--cue-font-scale`, and owns the persisted user preference.
130
+ * `--cue-font-scale`, owns the persisted user preference, and publishes the
131
+ * registry the three axis hooks read.
88
132
  *
89
133
  * Rendering: `<div data-cue-root data-theme data-density data-mode style={{ colorScheme, --cue-font-scale }}>`
90
134
  * (or the single child when `asChild`). The `theme`/`density`/`font`/`mode` props are
@@ -106,26 +150,72 @@ function writePreferences(storageKey, preferences) {
106
150
  * font pairing — `useTheme().font` and `setFont` inside one are the root's.
107
151
  *
108
152
  * @example
109
- * <ThemeProvider theme="terminal" density="ultra-compact" font="plex" mode="system">
153
+ * // `cue` and `terminal` are the `manifest.json` each theme package ships, read
154
+ * // from the `…/manifest.json` subpath of `@cueplusplus/theme-cue` and
155
+ * // `@cueplusplus/theme-terminal`. Written that way round on purpose: a literal
156
+ * // import statement in this comment reads, to every import-graph gate in this
157
+ * // repository, as `ui` depending on a theme package — which is the one thing
158
+ * // it may not do.
159
+ * <ThemeProvider themes={[cue, terminal]} theme="terminal" density="ultra-compact" font="plex" mode="system">
110
160
  * <App />
111
161
  * </ThemeProvider>
112
162
  */
113
- function ThemeProvider({ theme: themeProp = DEFAULT_THEME, density: densityProp = DEFAULT_DENSITY$1, mode: modeProp = DEFAULT_MODE, font: fontProp = DEFAULT_FONT, fontScale = 1, fontFamilies, storageKey = DEFAULT_STORAGE_KEY, persistPreferences = true, asChild = false, className, style: styleProp, children }) {
163
+ function ThemeProvider({ themes: themesProp, theme: themeProp, density: densityProp, mode: modeProp = DEFAULT_MODE, font: fontProp, fontScale = 1, fontFamilies, overrides, storageKey = DEFAULT_STORAGE_KEY, persistPreferences = true, asChild = false, className, style: styleProp, children }) {
114
164
  const ambient = React.useContext(ThemeContext);
115
165
  const isRoot = ambient === null;
116
- const [theme, setThemeState] = React.useState(themeProp);
117
- const [density, setDensityState] = React.useState(densityProp);
166
+ const ambientRegistry = React.useContext(ThemeRegistryContext);
167
+ const themes = themesProp ?? ambientRegistry?.themes ?? EMPTY;
168
+ const vocab = React.useMemo(() => vocabulary(themes), [themes]);
169
+ React.useMemo(() => {
170
+ if (process.env.NODE_ENV === "production") return;
171
+ for (const manifest of themes) {
172
+ const problems = validateManifest(manifest);
173
+ if (problems.length > 0) throw new TypeError(`ThemeProvider: manifest ${JSON.stringify(manifest.name)} is invalid\n${problems.map((p) => ` ${p.path}: ${p.message}`).join("\n")}`);
174
+ }
175
+ for (const manifest of themes) {
176
+ const rung = manifest.densities.default;
177
+ if (rung !== void 0 && !vocab.acceptsDensity(manifest.name, rung)) warnOnce(`manifest "${manifest.name}": densities.default "${rung}" is neither a base rung nor one this theme adds; the base default is used`);
178
+ const pairing = manifest.fontPairings.default;
179
+ if (pairing !== void 0 && !vocab.acceptsFont(manifest.name, pairing)) warnOnce(`manifest "${manifest.name}": fontPairings.default "${pairing}" is neither a base pairing nor one this theme adds; the base default is used`);
180
+ }
181
+ if (isRoot && themes.length === 0) warnOnce("ThemeProvider: no `themes` were registered, so there is no registry: useThemes() is empty, useTheme().manifest is null, the pre-paint script accepts any well-formed theme name out of storage, and no theme's added density rungs or font pairings are offered. Whatever [data-theme] blocks the page imported still paint. Pass themes={[…]} — see @cueplusplus/theme-base.");
182
+ }, [
183
+ themes,
184
+ vocab,
185
+ isRoot
186
+ ]);
187
+ const initialTheme = themeProp ?? (isRoot ? themes[0]?.name : ambient?.theme) ?? DEFAULT_THEME;
188
+ const initialDensity = densityProp ?? vocab.preferredDensity(initialTheme);
189
+ const initialFont = fontProp ?? vocab.preferredFont(initialTheme);
190
+ const [theme, setThemeState] = React.useState(initialTheme);
191
+ const [densityState, setDensityState] = React.useState(initialDensity);
118
192
  const [mode, setModeState] = React.useState(modeProp);
119
- const [rootFont, setRootFontState] = React.useState(() => {
120
- if (!isRoot || !persistPreferences) return fontProp;
121
- const stored = readPreferences(storageKey).font;
122
- return isOneOf(FONT_NAMES, stored) ? stored : fontProp;
193
+ const [storedSeed] = React.useState(() => {
194
+ if (!isRoot || !persistPreferences) return {
195
+ theme: initialTheme,
196
+ font: initialFont
197
+ };
198
+ const stored = readPreferences(storageKey);
199
+ const theme = vocab.acceptsTheme(stored.theme) ? stored.theme : initialTheme;
200
+ const fallback = fontProp ?? (theme === initialTheme ? initialFont : vocab.preferredFont(theme));
201
+ return {
202
+ theme,
203
+ font: vocab.acceptsFont(theme, stored.font) ? stored.font : fallback
204
+ };
123
205
  });
124
- const font = isRoot ? rootFont : ambient.font;
206
+ const [rootFont, setRootFontState] = React.useState(storedSeed.font);
207
+ const rawFont = isRoot ? rootFont : ambient.font;
125
208
  const [systemMode, setSystemMode] = React.useState(DEFAULT_RESOLVED_MODE);
126
- const resolvedMode = mode === "system" ? systemMode : mode;
127
209
  const restoredKey = React.useRef(null);
128
210
  const [restored, setRestored] = React.useState(false);
211
+ const manifest = React.useMemo(() => themes.find((m) => m.name === theme) ?? null, [themes, theme]);
212
+ if (vocab.themes !== null && manifest === null) warnOnce(`theme "${theme}" is not registered; useTheme().manifest is null and only the base axes are offered. Add its manifest to ThemeProvider's themes prop.`);
213
+ const density = vocab.acceptsDensity(theme, densityState) ? densityState : vocab.preferredDensity(theme);
214
+ if (density !== densityState) warnOnce(`density "${densityState}" is not offered by theme "${theme}"; using "${density}"`);
215
+ const fontTheme = isRoot && persistPreferences && !restored ? storedSeed.theme : theme;
216
+ const font = vocab.acceptsFont(fontTheme, rawFont) ? rawFont : vocab.preferredFont(fontTheme);
217
+ if (font !== rawFont) warnOnce(`font "${rawFont}" is not offered by theme "${fontTheme}"; using "${font}"`);
218
+ const resolvedMode = manifest?.supportsLight === false ? "dark" : mode === "system" ? systemMode : mode;
129
219
  useIsomorphicLayoutEffect(() => {
130
220
  if (!isRoot) return;
131
221
  if (!persistPreferences) {
@@ -136,10 +226,17 @@ function ThemeProvider({ theme: themeProp = DEFAULT_THEME, density: densityProp
136
226
  restoredKey.current = storageKey;
137
227
  setRestored(true);
138
228
  const stored = readPreferences(storageKey);
139
- if (isOneOf(THEMES, stored.theme)) setThemeState(stored.theme);
140
- if (isOneOf(DENSITY_LEVELS, stored.density)) setDensityState(stored.density);
141
- if (isOneOf(FONT_NAMES, stored.font)) setRootFontState(stored.font);
142
- if (isOneOf(MODES$1, stored.mode)) setModeState(stored.mode);
229
+ const next = acceptedPreferences(vocab, stored, {
230
+ theme,
231
+ density,
232
+ font,
233
+ mode
234
+ });
235
+ setThemeState(next.theme);
236
+ setDensityState(next.density);
237
+ setRootFontState(next.font);
238
+ setModeState(next.mode);
239
+ if (stored.theme !== void 0 && stored.theme !== next.theme || stored.density !== void 0 && stored.density !== next.density || stored.font !== void 0 && stored.font !== next.font || stored.mode !== void 0 && stored.mode !== next.mode) writePreferences(storageKey, next);
143
240
  }, [
144
241
  isRoot,
145
242
  persistPreferences,
@@ -159,10 +256,10 @@ function ThemeProvider({ theme: themeProp = DEFAULT_THEME, density: densityProp
159
256
  mode: modeProp,
160
257
  font: fontProp
161
258
  };
162
- if (previous.theme !== themeProp) setThemeState(themeProp);
163
- if (previous.density !== densityProp) setDensityState(densityProp);
259
+ if (previous.theme !== themeProp && themeProp !== void 0) setThemeState(themeProp);
260
+ if (previous.density !== densityProp && densityProp !== void 0) setDensityState(densityProp);
164
261
  if (previous.mode !== modeProp) setModeState(modeProp);
165
- if (previous.font !== fontProp) setRootFontState(fontProp);
262
+ if (previous.font !== fontProp && fontProp !== void 0) setRootFontState(fontProp);
166
263
  }, [
167
264
  themeProp,
168
265
  densityProp,
@@ -179,16 +276,16 @@ function ThemeProvider({ theme: themeProp = DEFAULT_THEME, density: densityProp
179
276
  }, []);
180
277
  const stateRef = React.useRef({
181
278
  theme,
182
- density,
279
+ density: densityState,
183
280
  mode,
184
- font
281
+ font: rawFont
185
282
  });
186
283
  useIsomorphicLayoutEffect(() => {
187
284
  stateRef.current = {
188
285
  theme,
189
- density,
286
+ density: densityState,
190
287
  mode,
191
- font
288
+ font: rawFont
192
289
  };
193
290
  });
194
291
  const persist = React.useCallback((patch) => {
@@ -226,17 +323,13 @@ function ThemeProvider({ theme: themeProp = DEFAULT_THEME, density: densityProp
226
323
  useIsomorphicInsertionEffect(() => {
227
324
  if (!isRoot || typeof document === "undefined") return;
228
325
  const root = document.documentElement;
229
- const next = restored || !persistPreferences ? {
326
+ const current = {
230
327
  theme,
231
328
  density,
232
329
  font,
233
330
  mode
234
- } : prepaintStamp(storageKey, {
235
- theme: themeProp,
236
- density: densityProp,
237
- font: fontProp,
238
- mode: modeProp
239
- });
331
+ };
332
+ const next = restored || !persistPreferences ? current : acceptedPreferences(vocab, readPreferences(storageKey), current);
240
333
  originalStamp.current ??= {
241
334
  theme: root.getAttribute("data-theme"),
242
335
  density: root.getAttribute("data-density"),
@@ -251,7 +344,7 @@ function ThemeProvider({ theme: themeProp = DEFAULT_THEME, density: densityProp
251
344
  write("data-density", next.density);
252
345
  write("data-font", next.font);
253
346
  write("data-mode", next.mode);
254
- const scheme = next.mode === "system" ? "light dark" : next.mode;
347
+ const scheme = (themes.find((m) => m.name === next.theme) ?? null)?.supportsLight === false ? "dark" : next.mode === "system" ? "light dark" : next.mode;
255
348
  if (root.style.colorScheme !== scheme) root.style.colorScheme = scheme;
256
349
  }, [
257
350
  isRoot,
@@ -262,10 +355,8 @@ function ThemeProvider({ theme: themeProp = DEFAULT_THEME, density: densityProp
262
355
  density,
263
356
  font,
264
357
  mode,
265
- themeProp,
266
- densityProp,
267
- fontProp,
268
- modeProp
358
+ themes,
359
+ vocab
269
360
  ]);
270
361
  React.useEffect(() => () => {
271
362
  const original = originalStamp.current;
@@ -284,6 +375,7 @@ function ThemeProvider({ theme: themeProp = DEFAULT_THEME, density: densityProp
284
375
  }, []);
285
376
  const contextValue = React.useMemo(() => ({
286
377
  theme,
378
+ manifest,
287
379
  density,
288
380
  mode,
289
381
  font,
@@ -294,6 +386,7 @@ function ThemeProvider({ theme: themeProp = DEFAULT_THEME, density: densityProp
294
386
  setFont
295
387
  }), [
296
388
  theme,
389
+ manifest,
297
390
  density,
298
391
  mode,
299
392
  font,
@@ -303,6 +396,8 @@ function ThemeProvider({ theme: themeProp = DEFAULT_THEME, density: densityProp
303
396
  setMode,
304
397
  setFont
305
398
  ]);
399
+ /** The axes on offer under the theme that is actually active. */
400
+ const registry = React.useMemo(() => buildRegistry(themes, theme), [themes, theme]);
306
401
  const parentFontFamilies = React.useContext(FontFamiliesContext);
307
402
  const effectiveFontFamilies = React.useMemo(() => {
308
403
  const sans = fontFamilies?.sans ?? parentFontFamilies?.sans;
@@ -317,17 +412,40 @@ function ThemeProvider({ theme: themeProp = DEFAULT_THEME, density: densityProp
317
412
  parentFontFamilies?.sans,
318
413
  parentFontFamilies?.mono
319
414
  ]);
415
+ const overridesKey = JSON.stringify(overrides ?? null);
416
+ const inline = React.useMemo(() => inlineOverrides(overrides, resolvedMode), [overridesKey, resolvedMode]);
417
+ const overridesStyle = React.useMemo(() => overridesCss(overrides), [overridesKey]);
418
+ const parentOverrides = React.useContext(OverridesContext);
419
+ const effectiveOverrides = React.useMemo(() => {
420
+ const merged = {
421
+ ...parentOverrides,
422
+ ...inline
423
+ };
424
+ return Object.keys(merged).length === 0 ? null : merged;
425
+ }, [parentOverrides, inline]);
426
+ React.useEffect(() => {
427
+ if (process.env.NODE_ENV === "production") return;
428
+ reportOverrides(manifest, resolvedMode, overrides, theme);
429
+ }, [
430
+ manifest,
431
+ resolvedMode,
432
+ overridesKey,
433
+ theme
434
+ ]);
320
435
  const style = React.useMemo(() => ({
321
- colorScheme: mode === "system" ? "light dark" : mode,
436
+ colorScheme: manifest?.supportsLight === false ? "dark" : mode === "system" ? "light dark" : mode,
322
437
  "--cue-font-scale": fontScale,
323
438
  ...fontFamilies?.sans === void 0 ? null : { "--cue-font-sans": fontFamilies.sans },
324
439
  ...fontFamilies?.mono === void 0 ? null : { "--cue-font-mono": fontFamilies.mono },
440
+ ...inline,
325
441
  ...styleProp
326
442
  }), [
443
+ manifest?.supportsLight,
327
444
  mode,
328
445
  fontScale,
329
446
  fontFamilies?.sans,
330
447
  fontFamilies?.mono,
448
+ inline,
331
449
  styleProp
332
450
  ]);
333
451
  const stamp = {
@@ -353,15 +471,28 @@ function ThemeProvider({ theme: themeProp = DEFAULT_THEME, density: densityProp
353
471
  style,
354
472
  children
355
473
  });
356
- return /* @__PURE__ */ jsx(ThemeContext.Provider, {
357
- value: contextValue,
358
- children: /* @__PURE__ */ jsx(FontScaleContext.Provider, {
359
- value: fontScale,
360
- children: /* @__PURE__ */ jsx(FontFamiliesContext.Provider, {
361
- value: effectiveFontFamilies,
362
- children: /* @__PURE__ */ jsx(DensityContext.Provider, {
363
- value: density,
364
- children: root
474
+ const densityLayer = overridesStyle === "" ? null : /* @__PURE__ */ jsx("style", {
475
+ "data-cue-overrides": "",
476
+ children: overridesStyle
477
+ });
478
+ return /* @__PURE__ */ jsx(ThemeRegistryContext.Provider, {
479
+ value: registry,
480
+ children: /* @__PURE__ */ jsx(ThemeContext.Provider, {
481
+ value: contextValue,
482
+ children: /* @__PURE__ */ jsx(AmbientThemeContext.Provider, {
483
+ value: theme,
484
+ children: /* @__PURE__ */ jsx(FontScaleContext.Provider, {
485
+ value: fontScale,
486
+ children: /* @__PURE__ */ jsx(FontFamiliesContext.Provider, {
487
+ value: effectiveFontFamilies,
488
+ children: /* @__PURE__ */ jsx(OverridesContext.Provider, {
489
+ value: effectiveOverrides,
490
+ children: /* @__PURE__ */ jsxs(DensityContext.Provider, {
491
+ value: density,
492
+ children: [root, densityLayer]
493
+ })
494
+ })
495
+ })
365
496
  })
366
497
  })
367
498
  })
@@ -0,0 +1,53 @@
1
+ import { ThemeRegistry } from "./vocabulary.js";
2
+ import "react";
3
+ import { DensityEntry, FontEntry, ThemeManifest } from "@cueplusplus/theme-base";
4
+ //#region src/system/theme-registry.d.ts
5
+ /**
6
+ * Every registered manifest, in registration order.
7
+ *
8
+ * The array a picker enumerates. It is the `themes` prop the nearest provider
9
+ * was handed, not a copy: registration is an application decision, and this
10
+ * library never discovers a theme by itself.
11
+ *
12
+ * @throws If called outside a `<ThemeProvider>`, like `useTheme()`.
13
+ * @example
14
+ * const themes = useThemes();
15
+ * const { theme, setTheme } = useTheme();
16
+ * return themes.map((m) => (
17
+ * <Chip key={m.name} selected={m.name === theme} onClick={() => setTheme(m.name)}>
18
+ * {m.name}
19
+ * </Chip>
20
+ * ));
21
+ */
22
+ declare function useThemes(): readonly ThemeManifest[];
23
+ /**
24
+ * The rungs an app may offer right now: the base five, then the ACTIVE theme's
25
+ * additions.
26
+ *
27
+ * Active, not registered: a rung a theme adds is emitted under that theme's
28
+ * `[data-theme]`, so offering another theme's rung would offer a control that
29
+ * changes nothing. Switch theme and this list changes with it.
30
+ *
31
+ * @throws If called outside a `<ThemeProvider>`.
32
+ * @example
33
+ * const rungs = useDensities();
34
+ * const { density, setDensity } = useTheme();
35
+ * <Select
36
+ * items={rungs.map((d) => ({ value: d.name, label: d.name }))}
37
+ * value={density}
38
+ * onValueChange={setDensity}
39
+ * />
40
+ */
41
+ declare function useDensities(): readonly DensityEntry[];
42
+ /**
43
+ * The font pairings, by the same rule: the base eight, then the active theme's.
44
+ *
45
+ * @throws If called outside a `<ThemeProvider>`.
46
+ * @example
47
+ * // The stacks a pairing resolves to, for a specimen that renders its own row.
48
+ * const { font } = useTheme();
49
+ * const sans = useFonts().find((f) => f.name === font)?.stacks["font-sans"];
50
+ */
51
+ declare function useFonts(): readonly FontEntry[];
52
+ //#endregion
53
+ export { type ThemeRegistry, useDensities, useFonts, useThemes };