@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.
Files changed (67) hide show
  1. package/CHANGELOG.md +272 -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/instruments/_ledger-disclosure.js +103 -0
  17. package/dist/instruments/_ledger.d.ts +21 -0
  18. package/dist/instruments/_ledger.js +5 -0
  19. package/dist/instruments/ledger.d.ts +112 -13
  20. package/dist/instruments/ledger.js +128 -38
  21. package/dist/midi/piano-keyboard.js +5 -1
  22. package/dist/primitives/chip.d.ts +1 -1
  23. package/dist/styles.css +23 -1
  24. package/dist/system/density.d.ts +17 -8
  25. package/dist/system/density.js +39 -16
  26. package/dist/system/index.d.ts +5 -2
  27. package/dist/system/index.js +2 -1
  28. package/dist/system/overrides.d.ts +43 -0
  29. package/dist/system/overrides.js +238 -0
  30. package/dist/system/portal.d.ts +4 -2
  31. package/dist/system/portal.js +34 -3
  32. package/dist/system/prepaint.d.ts +58 -7
  33. package/dist/system/prepaint.js +72 -20
  34. package/dist/system/theme-provider.d.ts +133 -8
  35. package/dist/system/theme-provider.js +203 -72
  36. package/dist/system/theme-registry.d.ts +53 -0
  37. package/dist/system/theme-registry.js +66 -0
  38. package/dist/system/use-density.d.ts +13 -5
  39. package/dist/system/use-density.js +142 -13
  40. package/dist/system/use-theme.d.ts +5 -3
  41. package/dist/system/use-theme.js +5 -3
  42. package/dist/system/vocabulary.d.ts +15 -0
  43. package/dist/system/vocabulary.js +111 -0
  44. package/dist/theming/contrast.d.ts +2 -122
  45. package/dist/theming/contrast.js +2 -194
  46. package/dist/theming/create-theme.d.ts +37 -11
  47. package/dist/theming/create-theme.js +54 -17
  48. package/dist/theming/index.d.ts +3 -4
  49. package/dist/theming/index.js +3 -4
  50. package/dist/theming/serialize.d.ts +24 -11
  51. package/dist/theming/serialize.js +16 -18
  52. package/manifest/components/colors-section.json +2 -3
  53. package/manifest/components/cue-portal-frame.json +1 -1
  54. package/manifest/components/data-tree.json +1 -0
  55. package/manifest/components/density.json +1 -1
  56. package/manifest/components/export-dialog.json +0 -3
  57. package/manifest/components/ledger.json +82 -7
  58. package/manifest/components/preset-section.json +2 -3
  59. package/manifest/components/shape-section.json +2 -3
  60. package/manifest/components/theme-configurator.json +0 -3
  61. package/manifest/components/theme-provider.json +24 -9
  62. package/manifest/components/token-editor.json +0 -3
  63. package/manifest/manifest.json +148 -27
  64. package/manifest/tokens.json +121 -11
  65. package/package.json +15 -6
  66. package/dist/theming/_presets.d.ts +0 -11
  67. package/dist/theming/_presets.js +0 -678
@@ -0,0 +1,238 @@
1
+ "use client";
2
+ import { noteOnce, warnOnce } from "./vocabulary.js";
3
+ import * as React from "react";
4
+ import { THEME_NAME_PATTERN, resolve } from "@cueplusplus/theme-base";
5
+ import { contrastReport } from "@cueplusplus/theme-base/contrast";
6
+ //#region src/system/overrides.ts
7
+ /**
8
+ * `overrides` — the one theme edit an application may make without owning a
9
+ * theme package.
10
+ *
11
+ * A theme is a package now, and the honest way to change one is to publish
12
+ * another. But there is a real band of edits below that: one accent for a
13
+ * customer's tenant, a font stack an app already loads, a rung that needs two
14
+ * more pixels for a touch build. Making each of those a package would be a
15
+ * build step for a value that changes per request, and the alternative every
16
+ * application reaches for otherwise — a stray `<style>` with `!important` in it
17
+ * — outranks the token layer everywhere at once and takes the density islands
18
+ * and the portals down with it.
19
+ *
20
+ * So the edits are typed, and they land where the cascade already expects them:
21
+ *
22
+ * - **Colours and fonts are inline custom properties on the provider root.**
23
+ * Inline beats every stylesheet, needs no `!important`, is scoped to the
24
+ * subtree by inheritance, and — because a portal mounts on `<body>` and
25
+ * inherits nothing from its owner — is re-stamped onto the portal container
26
+ * through {@link OverridesContext}, exactly like `--cue-font-scale` already is.
27
+ * - **Densities are one document-scoped `<style>`.** A rung is selected by
28
+ * attribute, not inherited, so it cannot be an inline declaration at all; and
29
+ * `useControlHeight`'s probe hangs off `document.body`, outside every provider
30
+ * root, so a subtree-scoped rule would leave the measurement and the paint
31
+ * disagreeing by exactly the override. See {@link overridesCss}.
32
+ *
33
+ * The dev pass measures the result: an override is a colour decision made
34
+ * without the generator that would have checked it, so this file checks it
35
+ * instead. See {@link reportOverrides}.
36
+ */
37
+ /**
38
+ * The prefix every token in this system is published under.
39
+ *
40
+ * Restated here rather than imported from `../theming`: that group reaches
41
+ * `culori` and the whole generator, and `system/` is in the import graph of
42
+ * every component in the package. One six-character constant is the cheaper
43
+ * copy. `test/system/overrides.test.tsx` holds it to `CUE_TOKEN_PREFIX`.
44
+ */
45
+ const PREFIX = "--cue-";
46
+ /**
47
+ * The already-flattened, referentially stable inline record the provider
48
+ * publishes for portals.
49
+ *
50
+ * Flattened and stable both matter. `usePortalStamp()` builds a fresh object on
51
+ * every render, so a `TokenOverrides` handed down raw would be a new dependency
52
+ * on every render of every overlay, and every open popover would re-write its
53
+ * container's custom properties for nothing. The provider computes this once,
54
+ * keyed on the serialised prop and the resolved mode, and hands the *same*
55
+ * object down until one of those actually moves.
56
+ *
57
+ * `null` means "nothing to stamp" — either no provider above, or a provider
58
+ * whose overrides publish no colour or font for this mode.
59
+ *
60
+ * Internal: consumers get this through the provider, never directly.
61
+ */
62
+ const OverridesContext = React.createContext(null);
63
+ /**
64
+ * The inline declarations for the resolved mode: colours and fonts.
65
+ *
66
+ * Only one colour block is ever emitted. The mode is a fact about the document
67
+ * at this instant, and publishing both blocks would mean the dark edit sat as a
68
+ * dead custom property under a light palette, waiting for somebody to `var()`
69
+ * it by mistake.
70
+ *
71
+ * No validation of the *values* here, deliberately: these go through React's
72
+ * `style` prop and `CSSStyleDeclaration.setProperty`, and the CSSOM drops a
73
+ * declaration it cannot parse. A bad colour is a colour that does not apply,
74
+ * not an injection — which is exactly the difference between this half of the
75
+ * feature and {@link overridesCss}, where the text is written into a stylesheet
76
+ * by hand and has to be guarded.
77
+ *
78
+ * @param overrides - The prop, or `undefined`.
79
+ * @param mode - The mode after `"system"` has been resolved.
80
+ * @returns Custom property name → value; empty when there is nothing to say.
81
+ * @example
82
+ * inlineOverrides({ colors: { light: { accent: "#b35900" } } }, "light");
83
+ * // → { "--cue-accent": "#b35900" }
84
+ */
85
+ function inlineOverrides(overrides, mode) {
86
+ const inline = {};
87
+ for (const [token, value] of Object.entries(overrides?.colors?.[mode] ?? {})) if (value !== void 0) inline[`${PREFIX}${token}`] = value;
88
+ const fonts = overrides?.fonts;
89
+ if (fonts?.sans !== void 0) inline[`${PREFIX}font-sans`] = fonts.sans;
90
+ if (fonts?.mono !== void 0) inline[`${PREFIX}font-mono`] = fonts.mono;
91
+ if (fonts?.display !== void 0) inline[`${PREFIX}font-display`] = fonts.display;
92
+ return inline;
93
+ }
94
+ /**
95
+ * A value that can be written into a stylesheet without closing the rule.
96
+ *
97
+ * The inline half needs no such guard — the CSSOM parses each declaration on
98
+ * its own — but this text is concatenated into a `<style>` by hand, so a value
99
+ * carrying `;`, `}` or `<` would end the declaration, the block or the element
100
+ * and let whatever follows be parsed as CSS. An override value can easily come
101
+ * from a tenant record or a query string, so the check is not theoretical.
102
+ */
103
+ const SAFE_VALUE = /^[^;{}<>\\]+$/;
104
+ /**
105
+ * The document-scoped stylesheet text for the density layer.
106
+ *
107
+ * **Why the selector says the attribute twice.** A rung a theme retunes ships
108
+ * as `[data-theme="t"] [data-density="x"]` — specificity (0,2,0). An override
109
+ * that has to beat the base rung *and* tie the theme's own has to reach (0,2,0)
110
+ * as well, and the honest way to write that on a single element is to repeat
111
+ * the attribute: no invented ancestor, no `!important`, no `:is()` trick whose
112
+ * specificity depends on its longest argument. Tied on specificity, source
113
+ * order decides — and the provider's element is appended to `<body>`, after
114
+ * every stylesheet in `<head>`, so the override wins. The same arithmetic puts
115
+ * it above the configurator's persisted snapshot, which writes bare
116
+ * `[data-density="x"]` at (0,1,0) into `<head>`.
117
+ *
118
+ * **Why it is document-scoped rather than nested under the provider root.**
119
+ * `useControlHeight()` measures `--cue-control-<size>` on a throwaway element
120
+ * appended to `document.body`, which is outside `[data-cue-root]`. A rule
121
+ * scoped under the root would leave that probe reading unoverridden geometry
122
+ * while the control beside it painted the override, and every consumer of the
123
+ * measured height — virtualised rows, canvas layout, the DMX grid — would be
124
+ * off by exactly the override. The cost of the document scope is stated in
125
+ * `ThemeProviderProps.overrides`: a nested provider's rung edits reach the
126
+ * whole page, the same way a theme's own rung rules do.
127
+ *
128
+ * @param overrides - The prop, or `undefined`.
129
+ * @returns One rule per rung, newline-separated; `""` when there is nothing to
130
+ * emit, which is the provider's signal to render no element at all.
131
+ * @example
132
+ * overridesCss({ densities: { compact: { "control-md": "1.75rem" } } });
133
+ * // → '[data-density="compact"][data-density="compact"]{--cue-control-md:1.75rem}'
134
+ */
135
+ function overridesCss(overrides) {
136
+ const rules = [];
137
+ for (const [rung, patch] of Object.entries(overrides?.densities ?? {})) {
138
+ if (!THEME_NAME_PATTERN.test(rung)) {
139
+ warnOnce(`overrides: density "${rung}" is not a usable rung name; its overrides are dropped`);
140
+ continue;
141
+ }
142
+ const declarations = [];
143
+ for (const [token, value] of Object.entries(patch ?? {})) {
144
+ if (value === void 0) continue;
145
+ if (!SAFE_VALUE.test(value)) {
146
+ warnOnce(`overrides: value ${JSON.stringify(value)} for "${token}" is not a CSS value`);
147
+ continue;
148
+ }
149
+ declarations.push(`${PREFIX}${token}:${value}`);
150
+ }
151
+ if (declarations.length === 0) continue;
152
+ rules.push(`[data-density="${rung}"][data-density="${rung}"]{${declarations.join(";")}}`);
153
+ }
154
+ return rules.join("\n");
155
+ }
156
+ /**
157
+ * Measure the palette an override actually produces, and name what it broke.
158
+ *
159
+ * `createTheme()` hands back a contrast report because a generated palette that
160
+ * nobody measured is a colour toy. An `overrides.colors` edit is the same
161
+ * decision made *without* the generator — one hex, dropped over a theme whose
162
+ * own build already cleared the eleven pairs — so it is measured here instead,
163
+ * against the palette it is layered onto.
164
+ *
165
+ * **Only what the override is answerable for is reported.** The palette is
166
+ * measured twice, once without the edit and once with it, and a pair is named
167
+ * when the edit introduced its failure, lowered its ratio, or *named either of
168
+ * its tokens*. Five of the ten themes this repository ships carry a **waived**
169
+ * required failure — a pair their own build recorded as known-bad — and
170
+ * reporting the overridden result flat would blame a tenant's accent for a pair
171
+ * it never touched, under a message that begins with the word `overrides:`. A
172
+ * report a reader learns to ignore is worse than no report. But a waiver covers
173
+ * the ink the *theme* shipped: the moment an app writes one of the two tokens
174
+ * itself, the pair is the app's, whichever way the ratio moved. See `caused()`.
175
+ *
176
+ * Failures `console.warn` and advisories `console.info`, matching the two tiers
177
+ * `contrastReport()` itself keeps: a failure is what makes a screen unreadable,
178
+ * an advisory is a ring nobody has been failed on yet.
179
+ *
180
+ * With no manifest registered for the active name the baseline is the blank
181
+ * base, which is what `resolve(null, …)` returns and what an unregistered theme
182
+ * can be measured against — the numbers are then the base's, not the palette
183
+ * the document is painted in. The message still names the theme the document is
184
+ * *stamped* with, because that is the screen the reader is looking at.
185
+ *
186
+ * Development only, and once per distinct message per process.
187
+ *
188
+ * @param manifest - The active theme's manifest, or `null` when unregistered.
189
+ * @param mode - The mode after `"system"` has been resolved.
190
+ * @param overrides - The prop, or `undefined`.
191
+ * @param theme - The name on `data-theme`, for the message.
192
+ */
193
+ function reportOverrides(manifest, mode, overrides, theme) {
194
+ if (process.env.NODE_ENV === "production") return;
195
+ const patch = overrides?.colors?.[mode];
196
+ if (patch === void 0 || Object.keys(patch).length === 0) return;
197
+ const base = resolve(manifest, { mode }).colors;
198
+ let before;
199
+ let after;
200
+ try {
201
+ before = new Map(contrastReport([{
202
+ mode,
203
+ tokens: base
204
+ }]).checks.map((check) => [check.id, check]));
205
+ after = contrastReport([{
206
+ mode,
207
+ tokens: {
208
+ ...base,
209
+ ...patch
210
+ }
211
+ }]);
212
+ } catch {
213
+ warnOnce(`overrides: a value in the ${mode} block is not a colour this can measure; the contrast pass was skipped`);
214
+ return;
215
+ }
216
+ /**
217
+ * A pair the edit is answerable for.
218
+ *
219
+ * Three of these clauses are about the *result* moving — newly measurable,
220
+ * newly failing, failing worse. The fourth is about authorship, and it is the
221
+ * one that took a review to find: an override that names either token of a
222
+ * pair has adopted that pair, even when the theme was already failing it and
223
+ * even when the edit improved the ratio without clearing the floor. Under
224
+ * `venu`, whose own build waived `accent-fg/accent` at 2.67, an app setting
225
+ * `accent-fg` to something that lands at 3.60 has made a colour decision of
226
+ * its own and is still under the 4.5 floor — and the theme's waiver covers
227
+ * the theme's ink, not the app's. Without the clause the report would go
228
+ * quiet on exactly the pair the app just took responsibility for.
229
+ */
230
+ const caused = (check) => {
231
+ const was = before.get(check.id);
232
+ return was === void 0 || was.passes || check.ratio < was.ratio || check.foreground in patch || check.background in patch;
233
+ };
234
+ for (const check of after.failures) if (caused(check)) warnOnce(`overrides: ${check.id} at ${check.ratio.toFixed(2)}:1 fails its ${check.minimum} floor under theme "${theme}"`);
235
+ for (const check of after.advisories) if (caused(check)) noteOnce(`overrides: ${check.id} at ${check.ratio.toFixed(2)}:1 misses its ${check.minimum} advisory floor under theme "${theme}"`);
236
+ }
237
+ //#endregion
238
+ export { OverridesContext, inlineOverrides, overridesCss, reportOverrides };
@@ -6,7 +6,8 @@ import * as React from "react";
6
6
  *
7
7
  * Returns a `container` element appended to `document.body` and stamped with
8
8
  * `data-theme` / `data-density` / `data-font` / `data-mode` (plus
9
- * `--cue-font-scale`) copied from the nearest provider and density island, and
9
+ * `--cue-font-scale` and any `ThemeProvider` `overrides` colour or font
10
+ * property) copied from the nearest provider and density island, and
10
11
  * with `data-cue-skin` / `data-cue-fidelity` when the caller sits inside an
11
12
  * `AgentSurface` island. The container is
12
13
  * `display: contents`, so it changes no layout and creates no containing block —
@@ -41,7 +42,8 @@ interface CuePortalFrameProps {
41
42
  *
42
43
  * Wraps `children` in a `display: contents` element carrying the same
43
44
  * `data-theme` / `data-density` / `data-font` / `data-mode` / `data-cue-skin` /
44
- * `data-cue-fidelity` stamp and `--cue-font-scale` as {@link useCuePortalProps}. Prefer the hook — a stamped container costs one
45
+ * `data-cue-fidelity` stamp, `--cue-font-scale` and `overrides` properties as
46
+ * {@link useCuePortalProps}. Prefer the hook — a stamped container costs one
45
47
  * element per overlay instead of one per render tree — and reach for this only
46
48
  * when the container prop is not available.
47
49
  *
@@ -2,6 +2,7 @@
2
2
  import { useIsomorphicLayoutEffect } from "./use-isomorphic-layout-effect.js";
3
3
  import { AgentSkinContext } from "./agent-skin.js";
4
4
  import { DensityContext } from "./density.js";
5
+ import { OverridesContext } from "./overrides.js";
5
6
  import { FontFamiliesContext, FontScaleContext, ThemeContext } from "./theme-provider.js";
6
7
  import * as React from "react";
7
8
  import { jsx } from "react/jsx-runtime";
@@ -19,13 +20,15 @@ const FONT_MONO_PROPERTY = "--cue-font-mono";
19
20
  * read the *body's* theme, which is exactly the bug this contract exists to
20
21
  * prevent. The same detachment is why `--cue-font-scale` travels here too: the
21
22
  * provider publishes it as an inline custom property, which a body-level portal
22
- * would otherwise never inherit.
23
+ * would otherwise never inherit — and why the provider's `overrides` do, for
24
+ * exactly the same reason and by the same route.
23
25
  */
24
26
  function usePortalStamp() {
25
27
  const theme = React.useContext(ThemeContext);
26
28
  const island = React.useContext(DensityContext);
27
29
  const fontScale = React.useContext(FontScaleContext);
28
30
  const fontFamilies = React.useContext(FontFamiliesContext);
31
+ const overrides = React.useContext(OverridesContext);
29
32
  const skin = React.useContext(AgentSkinContext);
30
33
  return {
31
34
  theme: theme?.theme ?? DEFAULT_THEME,
@@ -34,6 +37,7 @@ function usePortalStamp() {
34
37
  font: theme?.font ?? DEFAULT_FONT,
35
38
  fontScale,
36
39
  fontFamilies,
40
+ overrides,
37
41
  skin: skin?.skin ?? null,
38
42
  fidelity: skin?.fidelity ?? null
39
43
  };
@@ -44,7 +48,8 @@ function usePortalStamp() {
44
48
  *
45
49
  * Returns a `container` element appended to `document.body` and stamped with
46
50
  * `data-theme` / `data-density` / `data-font` / `data-mode` (plus
47
- * `--cue-font-scale`) copied from the nearest provider and density island, and
51
+ * `--cue-font-scale` and any `ThemeProvider` `overrides` colour or font
52
+ * property) copied from the nearest provider and density island, and
48
53
  * with `data-cue-skin` / `data-cue-fidelity` when the caller sits inside an
49
54
  * `AgentSurface` island. The container is
50
55
  * `display: contents`, so it changes no layout and creates no containing block —
@@ -92,11 +97,29 @@ function useCuePortalProps() {
92
97
  stamp.fontScale,
93
98
  stamp.fontFamilies?.sans,
94
99
  stamp.fontFamilies?.mono,
100
+ stamp.overrides,
95
101
  stamp.skin,
96
102
  stamp.fidelity
97
103
  ]);
98
104
  return React.useMemo(() => container === null ? {} : { container }, [container]);
99
105
  }
106
+ /**
107
+ * The override property names last written to each container.
108
+ *
109
+ * A stamp is applied over the top of the previous one, and `style.setProperty`
110
+ * only ever adds. So a key that *disappears* — the app switching to a mode
111
+ * whose block names different tokens, or dropping the prop — would leave its
112
+ * `--cue-*` on the container for the life of the overlay, with the in-tree
113
+ * content already painting without it. This is the record of what to take off.
114
+ *
115
+ * A `WeakMap` keyed on the element rather than an attribute on it: the same
116
+ * bookkeeping written as `data-cue-override-keys` would be one more
117
+ * document-visible mutation on every write, a second place for the truth to
118
+ * live, and a new `data-` attribute in a file whose whole contract is which
119
+ * `data-` attributes a portal carries. Containers are removed with the overlay
120
+ * and collected with it, which is what makes the weak reference the right one.
121
+ */
122
+ const stampedOverrides = /* @__PURE__ */ new WeakMap();
100
123
  /** Write a stamp onto a live element (the imperative half of the contract). */
101
124
  function applyStamp(element, stamp) {
102
125
  element.setAttribute("data-theme", stamp.theme);
@@ -107,10 +130,16 @@ function applyStamp(element, stamp) {
107
130
  else element.setAttribute("data-cue-skin", stamp.skin);
108
131
  if (stamp.fidelity === null) element.removeAttribute("data-cue-fidelity");
109
132
  else element.setAttribute("data-cue-fidelity", stamp.fidelity);
133
+ const overrides = stamp.overrides ?? {};
134
+ for (const property of stampedOverrides.get(element) ?? []) if (!(property in overrides)) element.style.removeProperty(property);
110
135
  if (stamp.fontScale === null) element.style.removeProperty(FONT_SCALE_PROPERTY);
111
136
  else element.style.setProperty(FONT_SCALE_PROPERTY, String(stamp.fontScale));
112
137
  setOptionalProperty(element, FONT_SANS_PROPERTY, stamp.fontFamilies?.sans);
113
138
  setOptionalProperty(element, FONT_MONO_PROPERTY, stamp.fontFamilies?.mono);
139
+ for (const [property, value] of Object.entries(overrides)) element.style.setProperty(property, value);
140
+ const keys = Object.keys(overrides);
141
+ if (keys.length === 0) stampedOverrides.delete(element);
142
+ else stampedOverrides.set(element, keys);
114
143
  }
115
144
  function setOptionalProperty(element, property, value) {
116
145
  if (value === void 0) element.style.removeProperty(property);
@@ -122,7 +151,8 @@ function setOptionalProperty(element, property, value) {
122
151
  *
123
152
  * Wraps `children` in a `display: contents` element carrying the same
124
153
  * `data-theme` / `data-density` / `data-font` / `data-mode` / `data-cue-skin` /
125
- * `data-cue-fidelity` stamp and `--cue-font-scale` as {@link useCuePortalProps}. Prefer the hook — a stamped container costs one
154
+ * `data-cue-fidelity` stamp, `--cue-font-scale` and `overrides` properties as
155
+ * {@link useCuePortalProps}. Prefer the hook — a stamped container costs one
126
156
  * element per overlay instead of one per render tree — and reach for this only
127
157
  * when the container prop is not available.
128
158
  *
@@ -136,6 +166,7 @@ function CuePortalFrame({ children, className, style }) {
136
166
  ...stamp.fontScale === null ? null : { [FONT_SCALE_PROPERTY]: stamp.fontScale },
137
167
  ...stamp.fontFamilies?.sans === void 0 ? null : { [FONT_SANS_PROPERTY]: stamp.fontFamilies.sans },
138
168
  ...stamp.fontFamilies?.mono === void 0 ? null : { [FONT_MONO_PROPERTY]: stamp.fontFamilies.mono },
169
+ ...stamp.overrides,
139
170
  ...style
140
171
  };
141
172
  return /* @__PURE__ */ jsx("div", {
@@ -1,4 +1,4 @@
1
- import { Density, FontName, Mode, ThemeName } from "@cueplusplus/tokens";
1
+ import { Density, FontName, Mode, ThemeManifest, ThemeName } from "@cueplusplus/theme-base";
2
2
  //#region src/system/prepaint.d.ts
3
3
  /** localStorage key the ThemeProvider and the pre-paint script share by default. */
4
4
  declare const DEFAULT_STORAGE_KEY = "cue-ui";
@@ -13,22 +13,49 @@ interface PersistedPreferences {
13
13
  /** Last font pairing the user picked. */
14
14
  font?: string;
15
15
  }
16
- /** Complete server-authoritative fallback stamped when storage is absent or ignored. */
16
+ /**
17
+ * The server-authoritative fallback stamped when storage is absent or ignored.
18
+ *
19
+ * `theme` and `mode` are required because nothing else can supply them: the
20
+ * theme is the app's own decision and the mode has no per-theme preference to
21
+ * fall back to. `density` and `font` are optional on purpose — leave either out
22
+ * and the script opens on the rung or the pairing `defaults.theme` itself
23
+ * declares (§5's `densities.default` / `fontPairings.default`), which is
24
+ * exactly what `<ThemeProvider>` opens on when the consumer passes no `density`
25
+ * or `font`. Naming one here is therefore the same statement as passing the
26
+ * prop: it overrides the theme's preference for the first paint, and the
27
+ * provider must be given the matching prop or the two disagree by a frame.
28
+ */
17
29
  interface PrepaintDefaults {
18
30
  theme: ThemeName;
19
- density: Density;
31
+ /** Initial density rung. Omitted, the theme's own `densities.default` decides. */
32
+ density?: Density;
20
33
  mode: Mode;
21
- /** Initial document-wide font pairing. Defaults to the token package's system pairing. */
34
+ /** Initial document-wide font pairing. Omitted, the theme's own `fontPairings.default` decides. */
22
35
  font?: FontName;
23
36
  }
24
37
  /** Options form for apps whose server projection, not localStorage, owns first paint. */
25
38
  interface PrepaintOptions {
26
39
  /** localStorage key shared with ThemeProvider. */
27
40
  storageKey?: string;
28
- /** Complete validated fallback triple. */
41
+ /** The validated fallback: `theme` and `mode` always, `density` and `font` only to override the theme's own. */
29
42
  defaults: PrepaintDefaults;
30
43
  /** Whether valid stored axes may override `defaults`. Defaults to `true`. */
31
44
  readStoredPreferences?: boolean;
45
+ /**
46
+ * The manifests this app registers, in the same order and of the same shape
47
+ * as `<ThemeProvider themes>`. The script inlines their names, and per theme
48
+ * the rungs and pairings that theme offers, so a stored preference is judged
49
+ * before paint by exactly the rule the provider applies after it.
50
+ *
51
+ * Omitted — and it is omitted by the positional overload, which cannot carry
52
+ * it — the script gets the empty-registry rule: any well-formed theme name is
53
+ * accepted, and density and font validate against the base axes alone. That
54
+ * is deliberate rather than lax. An app that registers nothing has whatever
55
+ * theme its stylesheet paints, and refusing the name it stored would repaint
56
+ * every returning visitor's first frame in a theme they did not choose.
57
+ */
58
+ themes?: readonly ThemeManifest[];
32
59
  }
33
60
  /**
34
61
  * Build the blocking inline script that stamps `data-theme`, `data-density`,
@@ -38,8 +65,32 @@ interface PrepaintOptions {
38
65
  * Render it as `<script dangerouslySetInnerHTML={{ __html: prepaintScript() }} />`
39
66
  * in `<head>`, above everything else. The output never contains `</script>` and
40
67
  * never throws: private-mode localStorage failures degrade to the dark-first
41
- * defaults (`cue` / `compact` / `system` / `dark`), which are also what bare
42
- * `:root` in `@cueplusplus/tokens/theme.css` already paints.
68
+ * defaults (`cue` / `compact` / `system` / `dark`). Two of those four are what
69
+ * bare `:root` already carries — `compact` geometry and the dark palette, from
70
+ * `@cueplusplus/theme-base/base.css` and the `@cueplusplus/tokens/axes.css` it
71
+ * imports. `cue` is not: since this release no stylesheet the library ships
72
+ * declares a `[data-theme="cue"]` block, so stamping that name paints the blank
73
+ * base unless the app imported `@cueplusplus/theme-cue/theme.css` itself. The
74
+ * script's job is to make the first frame agree with the first commit, and it
75
+ * still does — both are whatever the page's stylesheets say `cue` means.
76
+ *
77
+ * What the script *accepts* out of storage is decided by the same rule the
78
+ * provider applies a moment later. Pass the options form with `themes` — the
79
+ * array `<ThemeProvider themes>` is given — and the registered names are
80
+ * inlined, along with the rungs and pairings each theme offers, so a rung one
81
+ * theme adds is not accepted on first paint under a theme that does not have
82
+ * it. Pass nothing and the empty-registry rule applies: any well-formed theme
83
+ * name, and the base density and font axes. The positional overload below
84
+ * cannot carry a registry and always gets that rule.
85
+ *
86
+ * What it *stamps* for a visitor with nothing stored is decided the same way.
87
+ * The options form needs only `defaults.theme` and `defaults.mode`: leave
88
+ * `defaults.density` or `defaults.font` out and the script opens on the rung
89
+ * and the pairing that theme itself declares (§5's `densities.default` /
90
+ * `fontPairings.default`), which is precisely what `<ThemeProvider>` opens on
91
+ * when no `density` or `font` prop names one. Name either here and you are
92
+ * making the same statement the prop makes — so pass the matching prop, or the
93
+ * first paint and the first commit disagree by a frame.
43
94
  *
44
95
  * @param storageKey - localStorage key to read. Must match the `storageKey`
45
96
  * passed to `<ThemeProvider>`. Defaults to {@link DEFAULT_STORAGE_KEY}.
@@ -1,9 +1,20 @@
1
- import { DEFAULT_DENSITY, DEFAULT_FONT, DENSITIES, FONTS, MODES, THEMES } from "@cueplusplus/tokens";
1
+ import { vocabulary } from "./vocabulary.js";
2
+ import { DEFAULT_DENSITY, DEFAULT_FONT } from "@cueplusplus/tokens";
3
+ import { MODES } from "@cueplusplus/theme-base";
2
4
  //#region src/system/prepaint.ts
3
5
  /**
4
6
  * Pre-paint stamping. Server-safe by design: no `"use client"`, no React, no
5
7
  * DOM access at module scope — `app/layout.tsx` calls `prepaintScript()` on the
6
8
  * server and inlines the result before hydration.
9
+ *
10
+ * That is also why the vocabulary comes from `./vocabulary` and never from
11
+ * `./theme-registry`: the registry's React half carries a `"use client"`
12
+ * directive, and a module reached through a client boundary arrives in a server
13
+ * graph as a client *reference* rather than a function — calling one throws at
14
+ * prerender, in the app's root layout, where nothing has rendered yet.
15
+ * `test/system/server-safe.test.ts` pins the exact set of files this one
16
+ * reaches, so the next import that would break the rule fails a test instead of
17
+ * a deployment.
7
18
  */
8
19
  /** localStorage key the ThemeProvider and the pre-paint script share by default. */
9
20
  const DEFAULT_STORAGE_KEY = "cue-ui";
@@ -22,39 +33,80 @@ function prepaintScript(storageKeyOrOptions = DEFAULT_STORAGE_KEY, defaultTheme
22
33
  defaults: {
23
34
  theme: defaultTheme,
24
35
  density: defaultDensity,
25
- mode: "dark"
36
+ mode: "dark",
37
+ font: defaultFont
26
38
  },
27
39
  readStoredPreferences: true
28
40
  } : storageKeyOrOptions;
29
- if (options === null || typeof options !== "object" || options.defaults === void 0) throw new TypeError("prepaintScript options require a complete default triple");
41
+ if (options === null || typeof options !== "object" || options.defaults === void 0) throw new TypeError("prepaintScript options require a defaults object with theme and mode");
30
42
  const defaults = options.defaults;
31
- if (defaults.theme === void 0 || defaults.density === void 0 || defaults.mode === void 0) throw new TypeError("prepaintScript options require a complete default triple");
32
- if (!THEMES.includes(defaults.theme)) throw new TypeError("prepaintScript defaults contain an invalid theme");
33
- if (!DENSITIES.includes(defaults.density)) throw new TypeError("prepaintScript defaults contain an invalid density");
34
- if (!MODES.includes(defaults.mode)) throw new TypeError("prepaintScript defaults contain an invalid mode");
35
- if (defaults.font !== void 0 && !FONTS.includes(defaults.font)) throw new TypeError("prepaintScript defaults contain an invalid font");
43
+ if (defaults.theme === void 0 || defaults.mode === void 0) throw new TypeError("prepaintScript options require defaults.theme and defaults.mode");
44
+ const vocab = vocabulary(options.themes ?? []);
45
+ if (!vocab.acceptsTheme(defaults.theme)) throw new TypeError(vocab.themes === null ? `prepaintScript defaults.theme ${JSON.stringify(defaults.theme)} is not a valid theme name` : `prepaintScript defaults.theme ${JSON.stringify(defaults.theme)} is not a registered theme`);
46
+ if (defaults.density !== void 0 && !vocab.acceptsDensity(defaults.theme, defaults.density)) throw new TypeError(`prepaintScript defaults.density ${JSON.stringify(defaults.density)} is not offered by theme ${JSON.stringify(defaults.theme)}`);
47
+ if (!MODES.includes(defaults.mode)) throw new TypeError(`prepaintScript defaults.mode ${JSON.stringify(defaults.mode)} is not one of ${MODES.join(", ")}`);
48
+ if (defaults.font !== void 0 && !vocab.acceptsFont(defaults.theme, defaults.font)) throw new TypeError(`prepaintScript defaults.font ${JSON.stringify(defaults.font)} is not offered by theme ${JSON.stringify(defaults.theme)}`);
36
49
  if (options.readStoredPreferences !== void 0 && typeof options.readStoredPreferences !== "boolean") throw new TypeError("prepaintScript readStoredPreferences must be boolean");
37
50
  const storageKey = options.storageKey ?? "cue-ui";
38
51
  if (typeof storageKey !== "string") throw new TypeError("prepaintScript storageKey must be a string");
39
52
  const readStoredPreferences = options.readStoredPreferences ?? true;
40
53
  const key = jsStringLiteral(storageKey);
41
54
  const fallbackTheme = jsStringLiteral(defaults.theme);
42
- const fallbackDensity = jsStringLiteral(defaults.density);
55
+ const fallbackDensity = jsStringLiteral(defaults.density ?? vocab.preferredDensity(defaults.theme));
43
56
  const fallbackMode = jsStringLiteral(defaults.mode);
44
- const fallbackFont = jsStringLiteral(defaults.font ?? defaultFont);
45
- const themes = scriptSafe(JSON.stringify([...THEMES]));
46
- const densities = scriptSafe(JSON.stringify([...DENSITIES]));
47
- const modes = scriptSafe(JSON.stringify([...MODES]));
57
+ const fallbackFont = jsStringLiteral(defaults.font ?? vocab.preferredFont(defaults.theme));
58
+ const baseDensity = jsStringLiteral(DEFAULT_DENSITY);
59
+ const baseFont = jsStringLiteral(DEFAULT_FONT);
60
+ /**
61
+ * The registered names, or `null`.
62
+ *
63
+ * `null` tells the script "any well-formed name", which is the empty-registry
64
+ * rule (see `vocabulary`) and is what the positional overload — every app
65
+ * that has not migrated — gets. Anything else is the exact list the provider
66
+ * will validate against a moment later, inlined so the two cannot disagree.
67
+ */
68
+ const themes = scriptSafe(JSON.stringify(vocab.themes));
48
69
  /**
49
- * The pairings, from the package that authors them, for the same reason.
70
+ * The other two axes, `supportsLight`, and each theme's own preference, **per theme**.
50
71
  *
51
- * This is also the axis where an unvalidated read is worst: a stale or
52
- * hand-edited `font` would stamp an attribute the stylesheet has no block
53
- * for, `--cue-font-sans` would keep the base stack, and the page would render
54
- * in a family the picker says is not selected.
72
+ * Per theme, because a rung a theme adds is scoped by the cascade to that
73
+ * theme's `data-theme`; accepting it under another theme stamps an attribute
74
+ * no block will match, and the page renders at the rung it fell back from
75
+ * while the picker says otherwise. The keys are short (`d`, `f`, `l`, `p`,
76
+ * `q`) because this is inlined into every document this app serves, once per
77
+ * response, and with ten themes registered the long names would cost more
78
+ * bytes than the whole rest of the script.
79
+ *
80
+ * `p` and `q` are §5's `densities.default` / `fontPairings.default` — the
81
+ * rung and the pairing that theme prefers when nobody named one — read
82
+ * through `vocabulary()`, which is the same function `acceptedPreferences()`
83
+ * asks on the client. Two implementations of one rule, one of them written
84
+ * out as a string of JavaScript, is exactly the asymmetry the round-trip
85
+ * tests exist to catch; inlining the *answers* rather than re-deriving them
86
+ * is what makes the two sides agree by construction.
87
+ *
88
+ * They are omitted when they equal the base default, which is what every one
89
+ * of the ten shipped presets does — none of them declares either. The final
90
+ * clause already ends at the base literal, so an entry that repeats it is
91
+ * bytes in every response that change no answer. `a.p&&…` below is what
92
+ * reads an absent key as "no opinion".
93
+ *
94
+ * With nothing registered there is one entry under the empty key: the base
95
+ * ladder, the base pairings, and a light block, which the blank base has.
55
96
  */
56
- const pairings = scriptSafe(JSON.stringify([...FONTS]));
57
- return `!function(){try{var k=${key},e=document.documentElement,s=null;` + (readStoredPreferences ? "try{var r=localStorage.getItem(k);s=r?JSON.parse(r):null}catch(_){}" : "") + `s=s&&typeof s=="object"?s:{};var t=${themes}.indexOf(s.theme)>-1?s.theme:${fallbackTheme};var d=${densities}.indexOf(s.density)>-1?s.density:${fallbackDensity};var f=${pairings}.indexOf(s.font)>-1?s.font:${fallbackFont};var m=${modes}.indexOf(s.mode)>-1?s.mode:${fallbackMode};e.setAttribute("data-theme",t);e.setAttribute("data-density",d);e.setAttribute("data-font",f);e.setAttribute("data-mode",m);e.style.colorScheme=m==="system"?"light dark":m}catch(_){}}()`;
97
+ const axes = scriptSafe(JSON.stringify(Object.fromEntries((vocab.themes ?? [null]).map((name) => {
98
+ const preferredDensity = vocab.preferredDensity(name);
99
+ const preferredFont = vocab.preferredFont(name);
100
+ return [name ?? "", {
101
+ d: vocab.densitiesFor(name),
102
+ f: vocab.fontsFor(name),
103
+ l: vocab.supportsLight(name),
104
+ ...preferredDensity === DEFAULT_DENSITY ? {} : { p: preferredDensity },
105
+ ...preferredFont === DEFAULT_FONT ? {} : { q: preferredFont }
106
+ }];
107
+ }))));
108
+ const modes = scriptSafe(JSON.stringify([...MODES]));
109
+ return `!function(){try{var k=${key},e=document.documentElement,s=null;` + (readStoredPreferences ? "try{var r=localStorage.getItem(k);s=r?JSON.parse(r):null}catch(_){}" : "") + `s=s&&typeof s=="object"?s:{};var T=${themes},A=${axes},P=/^[a-z][a-z0-9-]*$/i;var t=T?(T.indexOf(s.theme)>-1?s.theme:${fallbackTheme}):(typeof s.theme=="string"&&P.test(s.theme)?s.theme:${fallbackTheme});var a=A[T?t:""]||A[""]||{d:[],f:[]};var d=a.d.indexOf(s.density)>-1?s.density:(a.d.indexOf(${fallbackDensity})>-1?${fallbackDensity}:(a.p&&a.d.indexOf(a.p)>-1?a.p:${baseDensity}));var f=a.f.indexOf(s.font)>-1?s.font:(a.f.indexOf(${fallbackFont})>-1?${fallbackFont}:(a.q&&a.f.indexOf(a.q)>-1?a.q:${baseFont}));var m=${modes}.indexOf(s.mode)>-1?s.mode:${fallbackMode};e.setAttribute("data-theme",t);e.setAttribute("data-density",d);e.setAttribute("data-font",f);e.setAttribute("data-mode",m);e.style.colorScheme=a.l===false?"dark":(m==="system"?"light dark":m)}catch(_){}}()`;
58
110
  }
59
111
  //#endregion
60
112
  export { DEFAULT_STORAGE_KEY, prepaintScript };