@cueplusplus/ui 0.7.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.
- package/CHANGELOG.md +272 -0
- package/README.md +49 -0
- package/dist/chat/message-list.js +2 -1
- package/dist/configurator/_export.d.ts +1 -1
- package/dist/configurator/_export.js +53 -13
- package/dist/configurator/_overrides.d.ts +25 -6
- package/dist/configurator/_overrides.js +30 -17
- package/dist/configurator/configurator.js +8 -3
- package/dist/configurator/panel-sections.js +43 -13
- package/dist/elements/command-palette.js +1 -1
- package/dist/elements/flow-graph.js +2 -2
- package/dist/elements/markdown.js +1 -1
- package/dist/elements/surfaces.js +4 -3
- package/dist/index.d.ts +5 -2
- package/dist/index.js +2 -1
- package/dist/instruments/_ledger-disclosure.js +103 -0
- package/dist/instruments/_ledger.d.ts +21 -0
- package/dist/instruments/_ledger.js +5 -0
- package/dist/instruments/ledger.d.ts +112 -13
- package/dist/instruments/ledger.js +128 -38
- package/dist/midi/piano-keyboard.js +5 -1
- package/dist/primitives/chip.d.ts +1 -1
- package/dist/styles.css +23 -1
- package/dist/system/density.d.ts +17 -8
- package/dist/system/density.js +39 -16
- package/dist/system/index.d.ts +5 -2
- package/dist/system/index.js +2 -1
- package/dist/system/overrides.d.ts +43 -0
- package/dist/system/overrides.js +238 -0
- package/dist/system/portal.d.ts +4 -2
- package/dist/system/portal.js +34 -3
- package/dist/system/prepaint.d.ts +58 -7
- package/dist/system/prepaint.js +72 -20
- package/dist/system/theme-provider.d.ts +133 -8
- package/dist/system/theme-provider.js +203 -72
- package/dist/system/theme-registry.d.ts +53 -0
- package/dist/system/theme-registry.js +66 -0
- package/dist/system/use-density.d.ts +13 -5
- package/dist/system/use-density.js +142 -13
- package/dist/system/use-theme.d.ts +5 -3
- package/dist/system/use-theme.js +5 -3
- package/dist/system/vocabulary.d.ts +15 -0
- package/dist/system/vocabulary.js +111 -0
- package/dist/theming/contrast.d.ts +2 -122
- package/dist/theming/contrast.js +2 -194
- package/dist/theming/create-theme.d.ts +37 -11
- package/dist/theming/create-theme.js +54 -17
- package/dist/theming/index.d.ts +3 -4
- package/dist/theming/index.js +3 -4
- package/dist/theming/serialize.d.ts +24 -11
- package/dist/theming/serialize.js +16 -18
- package/manifest/components/colors-section.json +2 -3
- package/manifest/components/cue-portal-frame.json +1 -1
- package/manifest/components/data-tree.json +1 -0
- package/manifest/components/density.json +1 -1
- package/manifest/components/export-dialog.json +0 -3
- package/manifest/components/ledger.json +82 -7
- package/manifest/components/preset-section.json +2 -3
- package/manifest/components/shape-section.json +2 -3
- package/manifest/components/theme-configurator.json +0 -3
- package/manifest/components/theme-provider.json +24 -9
- package/manifest/components/token-editor.json +0 -3
- package/manifest/manifest.json +148 -27
- package/manifest/tokens.json +121 -11
- package/package.json +15 -6
- package/dist/theming/_presets.d.ts +0 -11
- 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/
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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.
|
|
43
|
-
*
|
|
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`,
|
|
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
|
-
*
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
62
|
-
*
|
|
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
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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
|
|
70
|
-
const
|
|
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
|
|
73
|
-
density:
|
|
74
|
-
font:
|
|
75
|
-
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`,
|
|
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
|
-
*
|
|
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
|
|
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
|
|
117
|
-
const
|
|
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 [
|
|
120
|
-
if (!isRoot || !persistPreferences) return
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
326
|
+
const current = {
|
|
230
327
|
theme,
|
|
231
328
|
density,
|
|
232
329
|
font,
|
|
233
330
|
mode
|
|
234
|
-
}
|
|
235
|
-
|
|
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
|
-
|
|
266
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
children:
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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 };
|