@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,267 @@
1
1
  # @cueplusplus/ui
2
2
 
3
+ ## 0.9.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 5eb0da2: The ten colour presets leave `@cueplusplus/tokens` and become packages of their
8
+ own. `@cueplusplus/ui/styles.css` now ships the blank base instead of a palette,
9
+ so a theme is something you install, import and register — the same way a
10
+ third-party theme always should have been, and now is.
11
+
12
+ `@cueplusplus/tokens` keeps the two axes it actually owns: the five density
13
+ rungs and the eight font pairings, plus the contracts, `base.json` and the
14
+ primitives a theme source aliases. It emits `axes.css` and no colour at all.
15
+ Each preset is `@cueplusplus/theme-<name>` — `cue`, `dusk`, `hivehub`, `luma`,
16
+ `quotamate`, `requestport`, `signal`, `snuffle`, `terminal`, `venu` — carrying
17
+ the stylesheet it always carried, byte for byte: nothing about any shipped
18
+ palette moved, and a frozen snapshot of what `tokens@0.8.0` emitted is committed
19
+ so that stays checkable rather than merely asserted.
20
+
21
+ ## Migrating
22
+
23
+ **The unattributed default stops being `cue`.** An app that imports
24
+ `@cueplusplus/ui/styles.css` and registers nothing now paints the blank base.
25
+ Nothing errors and nothing falls back: a `data-theme` value is styled by
26
+ whichever stylesheet declares it, and with no theme package imported, none does.
27
+
28
+ Ten custom properties move in dark and seven in light. Measured on a root
29
+ stamped `data-theme="cue"`, which is what every `ThemeProvider` stamped before
30
+ this release and still stamps, so this is the delta for an app that changes
31
+ nothing:
32
+
33
+ | token | dark: `cue` → blank base | light: `cue` → blank base |
34
+ | ---------------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------ |
35
+ | `--cue-accent` | `#ffffff` → `#8a93a6` | `#0a0a0a` → `#3f4759` |
36
+ | `--cue-accent-hover` | `rgba(255,255,255,0.85)` → `#9aa3b6` | `rgba(0,0,0,0.85)` → `#333a4a` |
37
+ | `--cue-accent-soft` | follows `--cue-accent`: it is a 14% `color-mix` of it | same |
38
+ | `--cue-focus` | `#ffffff` → `#8a93a6` | `#0a0a0a` → `#3f4759` |
39
+ | `--cue-font-theme-mono` | JetBrains Mono → nothing; the base declares no theme mono | same as dark |
40
+ | `--cue-font-mono` | resolves through the line above, so JetBrains Mono → the platform stack | same as dark |
41
+ | `--cue-fg` | `#f5f5f7` → `#f5f5f5` | unchanged |
42
+ | `--cue-hair`, `--cue-hair-strong`, `--cue-row-hover` | follow `--cue-fg`: each is a `color-mix` of it (6%, 12%, 3.5%) | unchanged |
43
+ | `--cue-accent-fg` | unchanged | `#fcfcfc` → `#ffffff` |
44
+
45
+ The last four rows are the ones an eye catches and a list of "the accent and the
46
+ focus ring" would not have mentioned: four of the ten dark changes and the one
47
+ light change are tokens no theme file names directly, because they are mixes of
48
+ two that do.
49
+
50
+ To get `cue` back, exactly as it was:
51
+
52
+ ```bash
53
+ pnpm add @cueplusplus/theme-cue
54
+ ```
55
+
56
+ ```css
57
+ @import "@cueplusplus/ui/styles.css";
58
+ @import "@cueplusplus/theme-cue/theme.css";
59
+ ```
60
+
61
+ ```tsx
62
+ import cue from "@cueplusplus/theme-cue";
63
+
64
+ <ThemeProvider themes={[cue]} theme="cue">
65
+ {children}
66
+ </ThemeProvider>;
67
+ ```
68
+
69
+ The import order is load-bearing: a `[data-theme]` block and the base's bare
70
+ `:root` tie at (0,1,0), so the one declared later wins. Importing the theme
71
+ before `styles.css` paints the blank base over it. Importing the package from
72
+ its root, rather than the `manifest.json` subpath, also closes `ThemeName` to
73
+ the themes you installed, so a misspelt `theme=` is a type error.
74
+
75
+ Then the rest:
76
+
77
+ - **`@cueplusplus/tokens/theme.css` is a deprecated alias** of `axes.css` for
78
+ one minor. It still resolves and still delivers the geometry and type axes, so
79
+ a stylesheet naming it keeps building — it simply carries no palette. Change
80
+ the line to `@cueplusplus/tokens/axes.css`, or drop it: `ui/styles.css` already
81
+ brings the axes in through `@cueplusplus/theme-base/base.css`.
82
+ - **Gone from `@cueplusplus/tokens`:** the `themes/*.css` and
83
+ `registry/theme-*.json` subpaths, and the `THEMES`, `ThemeName`,
84
+ `THEME_SUPPORTS_LIGHT` and `DEFAULT_THEME` exports. `ThemeName` now comes from
85
+ `@cueplusplus/theme-base` (re-exported by `@cueplusplus/ui`) and widens with
86
+ each theme package installed, rather than naming the ten that happened to be
87
+ compiled in; whether a theme ships a light block is `supportsLight` on its own
88
+ `manifest.json`; and there is no default palette to name, because the library
89
+ contains none.
90
+ - **`@cueplusplus/tokens`' root entry is now `dist/tokens.js`**, with
91
+ `dist/tokens.d.ts` beside it, where it used to be the TypeScript source
92
+ `dist/tokens.ts`. The import specifier, the exported names and their values
93
+ are unchanged — `import { DENSITIES } from "@cueplusplus/tokens"` is the same
94
+ line it was — but the file behind it is one Node can execute. It could not
95
+ before: Node refuses to strip types for anything under `node_modules`, so the
96
+ package worked in a workspace and failed from a registry install, taking
97
+ `@cueplusplus/theme-base` and the `cue-theme` CLI with it. If you added
98
+ `@cueplusplus/tokens` to your bundler's transpile list to work around that
99
+ (Next.js' `transpilePackages`, or the equivalent), take it out.
100
+ - **`@cueplusplus/tokens` gains `base.json`'s `fonts.handshake`**: the three
101
+ literals of the monospace resolution (`font-pairing-mono`, `font-theme-mono`,
102
+ and the `var()` that resolves them), published as data so a theme built
103
+ anywhere spells them the same way this build does.
104
+ - **`@cueplusplus/ui`'s `THEME_NAME_PATTERN` and `RESOLVED_MONO` are now
105
+ re-exports** of `@cueplusplus/theme-base`'s. Same names, same values, one
106
+ definition instead of three.
107
+ - **`/r/tokens.json`'s `themes` are objects**, not strings:
108
+ `{ name, package, supportsLight, densities }`. A consumer reading the registry
109
+ for a theme list wants to know which package delivers it.
110
+ - **Every published tarball now carries a `README.md`.** On a registry the
111
+ package page _is_ the README, and `tokens`, `ui` and `brand-tokens` shipped
112
+ without one; `packages/release/test/publishable-packages.test.mjs` holds all
113
+ fifteen to it now, beside the same check for `CHANGELOG.md`.
114
+ - **`@cueplusplus/ui` depends on `@cueplusplus/theme-base@^1`**, a range rather
115
+ than a pin, deliberately: two copies of `theme-base` in one tree would break
116
+ the type registry, because a theme's `declare module` augments one copy while
117
+ `ui`'s re-exported `ThemeName` resolves against the other, and neither errors.
118
+
119
+ - 1b98830: `ThemeProvider` takes `themes`, an ordered array of theme manifests, and the runtime reads
120
+ everything about a theme from its manifest instead of from a copy inside this package. Types open:
121
+ `ThemeName`, `DensityLevel` (`Density` on `@cueplusplus/theme-base`) and `FontName` are now `@cueplusplus/theme-base`'s, which a theme package
122
+ widens by declaration merging — `"foo"` is a valid theme in an app that installed
123
+ `@cueplusplus/theme-foo`, and `ThemeName` is `string` in one that installed none. Three hooks —
124
+ `useThemes()`, `useDensities()`, `useFonts()` — return what the active theme offers. Every place
125
+ that validated a persisted name against a frozen list validates against the registry. A typed
126
+ `overrides` prop layers colours, fonts and densities over the active theme, with a development
127
+ contrast report.
128
+
129
+ ## Migrating
130
+ - **`themes` is optional for this release.** An app that passes none gets the blank base and the
131
+ base axes, with one development warning. Pass `themes={[cue]}` from `@cueplusplus/theme-cue`
132
+ when it publishes. With nothing registered there is no theme vocabulary to be out of, so
133
+ `ThemeProvider` and `prepaintScript` no longer reject a persisted or default theme name they do
134
+ not recognise: a well-formed name is stamped as-is. Pass `themes` to close the vocabulary again.
135
+ - **`createTheme({ base })` takes a manifest, not a name.** `createTheme({ name, base: "terminal" })`
136
+ becomes `createTheme({ name, base: terminal })` with `import terminal from "@cueplusplus/theme-terminal"`.
137
+ With no `base` it starts from the blank base. `DEFAULT_THEME_BASE` is removed.
138
+ - **`createTheme`'s radius rules now emit both selector forms** — `[data-theme="x"] [data-density="y"]`
139
+ beside `[data-theme="x"][data-density="y"]` — so a `<Density>` island and `useControlHeight`'s probe
140
+ get the crossed value too. A byte-comparison against the old output will differ on those lines.
141
+ - **`ThemeScope` from `@cueplusplus/ui/configurator` gains `manifest: ThemeManifest | null`.** Every
142
+ function that takes a scope needs it — `baseValue`, `resolveValue`, `resolvedTokens`, `setOverride`,
143
+ `clearOverride`, `isOverridden`, `scopeKey`, `exportName`, `buildExports({ scope })` and
144
+ `<ExportDialog scope>`. Pass the active manifest (`useTheme().manifest`) or `null` for the blank
145
+ base. The panel's `create-theme` export now emits an import of the base theme's manifest instead
146
+ of `base: "<name>"`.
147
+ - **`overrides` is typed `TokenOverrides`**, exported from `@cueplusplus/ui` and `@cueplusplus/ui/system`.
148
+ It is unrelated to `ThemeOverrides` on `@cueplusplus/ui/configurator`, which is still the
149
+ configurator's edit snapshot.
150
+ - `THEME_COLOR_TOKENS` is now exactly `COLOR_CONTRACT` from `@cueplusplus/tokens`: the same 26 names
151
+ in the same order.
152
+ - **`ThemeProvider` opens on the active theme's `densities.default` and `fontPairings.default`** when
153
+ you pass no `density` or `font` — the rung and pairing a theme declares it prefers. Pass one to
154
+ override it for every theme. No first-party theme declares either, so nothing shipped moves.
155
+ `prepaintScript` opens on the same tier: `defaults.density` and `defaults.font` are both optional
156
+ in the options form, and an absent one falls to that theme's declared preference rather than to the
157
+ library's base rung, so the blocking script stamps exactly what the provider is about to commit
158
+ with no work on your side. Passing `defaults.density` or `defaults.font` is the same statement as
159
+ passing the prop — do both, or neither. One consequence for the positional form: its fourth
160
+ argument is now validated like the other three, so `prepaintScript(key, theme, density, font)` with
161
+ a pairing outside the base eight throws the same `defaults.font … is not offered by theme …` the
162
+ options form gives, where before the script silently fell back to `system`.
163
+ - **Three types 16 public signatures name are now exported** from `@cueplusplus/ui` and
164
+ `@cueplusplus/ui/system`: `ThemeManifest`, `DensityEntry` and `FontEntry`. They appear in
165
+ `ThemeProviderProps.themes`, `PrepaintOptions.themes`, `ThemeContextValue.manifest`,
166
+ `ThemeRegistry`, `useThemes()`, `useDensities()`, `useFonts()` and `ThemeScope.manifest`, so
167
+ `@cueplusplus/ui` re-exports them: an app that names one of those types in a signature of its own
168
+ need not reach past the package it installed.
169
+ - **`ThemeRegistry` is exported** from `@cueplusplus/ui/system` — the shape the three registry hooks
170
+ read: the registered manifests, the first one's name, and the rungs and pairings the _active_ theme
171
+ offers.
172
+ - **`RESOLVED_MONO` is exported** from `@cueplusplus/ui/theming`: the `var()` chain every theme block
173
+ sets `--cue-font-mono` to, pairing first. A tool that writes theme CSS beside `serializeThemeCss`
174
+ needs the same string.
175
+ - **`themeSelector`'s options gain `pair?: boolean`**, which emits the descendant form
176
+ `[data-theme="x"] [data-density="y"]` beside the compound one, as more than one selector when
177
+ `density` is set. Neither a `<Density>` island nor `useControlHeight`'s `document.body` probe
178
+ carries `data-theme` beside its `data-density`, so this is what reaches them. This function, and
179
+ `@cueplusplus/theme-tools`' `cue-theme build`, now emit three forms per rung instead of two — an
180
+ exact match, a structural fallback, and the unchanged compound — so that a rung a theme adds or
181
+ retunes no longer applies inside a nested provider of another theme: not through a `<Density>`
182
+ island, not through the nested provider's own root opening on a rung the outer theme happens to
183
+ retune, and not even when that outer theme is _re-entered_ after a detour through a different one
184
+ several levels in, which an ancestor-only structural check cannot always tell apart from the
185
+ outer detour alone. **`<Density>` and the internal measuring probe now also render
186
+ `data-cue-theme`**, stamped from the nearest `<ThemeProvider>` (or omitted where there is none) —
187
+ an inert attribute nothing in this library reads back except the exact-match selector above, which
188
+ needs it to resolve "nearest theme" the way `useContext` already does, rather than the "does some
189
+ ancestor carry this theme" a pure CSS selector is limited to. All three forms are wrapped in
190
+ `:where()`, which contributes no specificity, so together they tie with the `overrides` prop's own
191
+ document-scoped density rule at (0,2,0) — as they always did — rather than outranking it.
192
+
193
+ - e587341: `contrastReport()` now measures the focus ring.
194
+
195
+ `CONTRAST_REQUIREMENTS` gains a second tier: nine **advisory** pairs holding
196
+ `--cue-focus` and `--cue-danger` — the two roles a form control paints as a
197
+ ring, and therefore WCAG 1.4.11 non-text contrast surfaces — to 3:1 against
198
+ every ground one can be painted over. `ContrastRequirement` carries
199
+ `advisory?: boolean`, `ContrastReport` carries `advisories` beside `failures`,
200
+ and `passes` still counts only the eleven required pairs. No theme that was
201
+ clean yesterday fails today; every theme now gets told about its ring.
202
+
203
+ The gap was known and the gate for it lived in a test file, which protected the
204
+ ten shipped presets and nobody who calls `createTheme()`. The dark block takes
205
+ the accent into `--cue-focus` verbatim, so
206
+ `createTheme({ name: "acme", base: snuffle, accent: "#3b3f8f" })` — the manifest
207
+ imported from `@cueplusplus/theme-snuffle`, and a brand colour straight into the
208
+ anchor, which is the documented use — came back
209
+ `passes: true` with twenty-two checks, none of which named `focus`, over a ring
210
+ measuring 2.01:1 down to 1.55:1 on the five grounds it can sit on. The same call
211
+ now returns those five pairs in `report.advisories`, each with its reason. It is
212
+ the failure 0.6.0 fixed in `luma`, `venu` and `hivehub` at 2.45:1, and a report
213
+ is the only place a consumer would ever meet it: an invisible focus ring is
214
+ invisible in code review and invisible in a screenshot taken with a mouse.
215
+
216
+ All ten shipped presets clear the new tier in both modes — two hundred ring
217
+ measurements, the tightest `terminal.dark --cue-danger` on `--cue-surface-3` at
218
+ 3.13:1 — and the preset gate now runs through `contrastReport()` rather than
219
+ through its own copy of the same loop.
220
+
221
+ `danger/bg` is not restated in the ring tier: the status pairs have always held
222
+ the red against the page at this same floor, as a required pair, and one
223
+ measurement gets one id.
224
+
225
+ **If you iterate `CONTRAST_REQUIREMENTS` or render `report.checks`,** the array
226
+ goes from 11 entries to 20 and a two-block report from 22 checks to 40. Filter
227
+ on `advisory` to keep the old set.
228
+
229
+ ### Patch Changes
230
+
231
+ - cf80a87: `FlowGraph` draws an edge faint until both of the nodes it joins have been
232
+ reached, which is what its `edges` prop has always said it does.
233
+
234
+ The behaviour was documented, published and unimplemented: the prop's
235
+ description — "An edge dims until both of its ends are visible" — ships in
236
+ `manifest/components/flow-graph.json` and in the type declarations, the
237
+ `transition-opacity duration-500` was already on the path, and the component
238
+ computed whether an edge was live and then never used the answer. Every edge
239
+ painted at full strength from the first frame, so a graph revealing itself
240
+ through `visibleCount` showed all of its connections before it had any of its
241
+ nodes.
242
+
243
+ Found by turning the lint gate on: the unused binding was the first thing it
244
+ reported.
245
+
246
+ - 2737aec: `@cueplusplus/tokens` exports the contracts and the axes as data, so a theme
247
+ built outside this repository can resolve over the same base: `COLOR_CONTRACT`,
248
+ `GEOMETRY_CONTRACT` and `FONT_TOKENS` as typed tuples, `base.json` (every
249
+ density's geometry, the pairings' stacks, the derived templates, the defaults),
250
+ `primitives.tokens.json` (the tier-1 file a theme source aliases), and
251
+ `axes.css` — `theme.css` with the theme axis removed. Nothing that exists today
252
+ moves; `theme.css` is unchanged.
253
+
254
+ `@cueplusplus/ui/theming` keeps every export it had. The contrast arithmetic now
255
+ lives in `@cueplusplus/theme-base` and is re-exported here; `ui` gains that
256
+ package as a dependency. No behaviour changes.
257
+
258
+ - Updated dependencies [cf80a87]
259
+ - Updated dependencies [5eb0da2]
260
+ - Updated dependencies [814c859]
261
+ - Updated dependencies [2737aec]
262
+ - @cueplusplus/tokens@0.9.0
263
+ - @cueplusplus/theme-base@1.0.0
264
+
3
265
  ## 0.8.0
4
266
 
5
267
  ### Minor Changes
package/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # @cueplusplus/ui
2
+
3
+ Components for console software: dense, keyboard-first interfaces where a screen is mostly data.
4
+ Base UI underneath for behaviour and accessibility, Tailwind v4 on top for the paint.
5
+
6
+ ```sh
7
+ pnpm add @cueplusplus/ui @cueplusplus/tokens @cueplusplus/theme-cue @base-ui/react
8
+ ```
9
+
10
+ ```css
11
+ @import "tailwindcss";
12
+ @import "@cueplusplus/ui/styles.css";
13
+ @import "@cueplusplus/theme-cue/theme.css";
14
+ ```
15
+
16
+ ```tsx
17
+ import cue from "@cueplusplus/theme-cue";
18
+ import { ThemeProvider } from "@cueplusplus/ui/system";
19
+
20
+ <ThemeProvider themes={[cue]} theme="cue" density="compact" mode="system">
21
+ <App />
22
+ </ThemeProvider>;
23
+ ```
24
+
25
+ The order of those three `@import`s is load-bearing, and the `themes` prop is not optional in
26
+ practice: this library contains no palette. Install and register a `@cueplusplus/theme-*` package,
27
+ or every surface paints `@cueplusplus/theme-base`'s blank base — greys, one desaturated accent,
28
+ the platform monospace — with nothing erroring to tell you.
29
+
30
+ Common components come from the root entry. Groups that need an optional peer have a subpath of
31
+ their own (`/charts`, `/date`, `/flow`, `/color`, `/agent-runtime`, and the rest), so importing a
32
+ button never pulls a charting library into your bundle.
33
+
34
+ ## The machine-readable half
35
+
36
+ This package ships its own manifest: `@cueplusplus/ui/manifest.json` is an index of every
37
+ component with a SHA-256 for each companion document, and
38
+ `@cueplusplus/ui/manifest/components/<name>.json` is one component in full — props, variants, the
39
+ tokens it paints with, and the hand-written notes on when it is the wrong choice. An agent or a
40
+ code generator should read that rather than guess a prop name.
41
+
42
+ The same surfaces are published at <https://ui.cueplusplus.com>, with the docs site, the live
43
+ kitchen sink and the CUE++ agent skills beside them.
44
+
45
+ ## Full documentation
46
+
47
+ - Installing and using it from another project: [`docs/CONSUMING.md`](https://github.com/cueplusplus/product-design-system/blob/main/docs/CONSUMING.md)
48
+ - Every component, with props and a live example: <https://ui.cueplusplus.com/docs/components>
49
+ - Theming, density and the `--cue-*` contract: <https://ui.cueplusplus.com/docs/theming>
@@ -42,6 +42,7 @@ const MessageList = React.forwardRef(function MessageList({ className, children,
42
42
  const [behind, setBehind] = React.useState(false);
43
43
  const lastHeightRef = React.useRef(0);
44
44
  const count = React.Children.count(children);
45
+ const isEmpty = count === 0;
45
46
  const previousCountRef = React.useRef(count);
46
47
  const jumpToLatest = React.useCallback(() => {
47
48
  const element = scrollerRef.current;
@@ -90,7 +91,7 @@ const MessageList = React.forwardRef(function MessageList({ className, children,
90
91
  }, [
91
92
  follow,
92
93
  pinned,
93
- count === 0
94
+ isEmpty
94
95
  ]);
95
96
  const onScroll = React.useCallback(() => {
96
97
  const element = scrollerRef.current;
@@ -61,7 +61,7 @@ interface BuildExportsOptions {
61
61
  * @param name - What the user typed, if anything.
62
62
  * @returns A usable CSS identifier.
63
63
  * @example
64
- * exportName({ theme: "terminal", mode: "dark" }); // → "terminal-custom"
64
+ * exportName({ theme: "terminal", mode: "dark", manifest: terminal }); // → "terminal-custom"
65
65
  */
66
66
  declare function exportName(scope: ThemeScope, name?: string): string;
67
67
  /**
@@ -43,7 +43,7 @@ const DTCG_ALIASES = { density: "density-factor" };
43
43
  * @param name - What the user typed, if anything.
44
44
  * @returns A usable CSS identifier.
45
45
  * @example
46
- * exportName({ theme: "terminal", mode: "dark" }); // → "terminal-custom"
46
+ * exportName({ theme: "terminal", mode: "dark", manifest: terminal }); // → "terminal-custom"
47
47
  */
48
48
  function exportName(scope, name) {
49
49
  const proposed = name?.trim() ?? "";
@@ -51,13 +51,22 @@ function exportName(scope, name) {
51
51
  }
52
52
  /** A JSON string, printed the way a hand-written source file would print it. */
53
53
  const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
54
- /** Both modes of a scope, so an export can carry the palette the user is not looking at. */
54
+ /**
55
+ * Both modes of a scope, so an export can carry the palette the user is not
56
+ * looking at.
57
+ *
58
+ * Spread rather than rebuilt: a fresh literal would have to restate `manifest`,
59
+ * and the shape of that mistake is a `null` that resolves the off-mode palette
60
+ * to the blank base — so the `dtcg` and `registry` exports would carry the right
61
+ * colours for the mode on screen and the neutral ramp for the other one, with
62
+ * nothing to see until somebody installed the theme.
63
+ */
55
64
  function bothModes(scope) {
56
65
  return [{
57
- theme: scope.theme,
66
+ ...scope,
58
67
  mode: "dark"
59
68
  }, {
60
- theme: scope.theme,
69
+ ...scope,
61
70
  mode: "light"
62
71
  }];
63
72
  }
@@ -71,8 +80,11 @@ function movedTokens(overrides, scope, kind) {
71
80
  * ② The DTCG token file.
72
81
  *
73
82
  * A whole theme rather than a diff: the point of this format is that the result
74
- * can be dropped into `packages/tokens/src/themes/` (or anyone else's DTCG
75
- * pipeline) and built, which a patch file cannot. Groups follow the sources —
83
+ * can be dropped into a theme package's `src/` — `cue-theme init` scaffolds one,
84
+ * and `packages/theme-<name>/src/<name>.dark.tokens.json` is the shape — or into
85
+ * anyone else's DTCG pipeline, and built, which a patch file cannot. (It used to
86
+ * say `packages/tokens/src/themes/`; that directory is gone, and a palette is a
87
+ * package now.) Groups follow the sources —
76
88
  * `color` for the palette, `type` for the stacks, `density` for the multipliers
77
89
  * — and appear only when they carry something.
78
90
  */
@@ -121,9 +133,25 @@ function dtcgFile(overrides, scope, name) {
121
133
  function quote(value) {
122
134
  return value.includes("\"") && !value.includes("'") && !value.includes("\\") ? `'${value}'` : JSON.stringify(value);
123
135
  }
136
+ /** What a JavaScript binding may be called, and what a printed key may go unquoted as. */
137
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
124
138
  /** One `key: "value"` line of an object literal, quoting the key only when it must. */
125
139
  function property(key, value, indent) {
126
- return `${indent}${/^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key)}: ${quote(value)},`;
140
+ return `${indent}${IDENTIFIER.test(key) ? key : JSON.stringify(key)}: ${quote(value)},`;
141
+ }
142
+ /**
143
+ * What to call the imported base manifest in the snippet.
144
+ *
145
+ * The theme's own name where that is a legal binding — `terminal` reads as what
146
+ * it is — and `base` where it is not (a theme may be called `acme-dark`, which
147
+ * is a fine `data-theme` value and not an identifier). The suffix exists for the
148
+ * one collision this can produce: a user who names their export exactly what the
149
+ * base theme is called would otherwise get `const terminal = createTheme({ base:
150
+ * terminal })`, which is a snippet that does not run.
151
+ */
152
+ function baseIdentifier(theme, exported) {
153
+ const proposed = IDENTIFIER.test(theme) ? theme : "base";
154
+ return proposed === exported ? `${proposed}Base` : proposed;
127
155
  }
128
156
  /**
129
157
  * ③ The `createTheme()` call.
@@ -134,7 +162,17 @@ function property(key, value, indent) {
134
162
  * a snippet that quietly loses half an edit is worse than no snippet.
135
163
  */
136
164
  function createThemeSnippet(overrides, scope, name) {
137
- const lines = [` name: ${JSON.stringify(name)},`, ` base: ${JSON.stringify(scope.theme)},`];
165
+ const exported = IDENTIFIER.test(name) ? name : "theme";
166
+ const base = scope.manifest;
167
+ let baseImport = null;
168
+ let baseAnchor = null;
169
+ if (base !== null) {
170
+ const ident = baseIdentifier(scope.theme, exported);
171
+ baseImport = `import ${ident} from ${JSON.stringify(base.package)};`;
172
+ baseAnchor = ` base: ${ident},`;
173
+ }
174
+ const lines = [` name: ${JSON.stringify(name)},`];
175
+ if (baseAnchor !== null) lines.push(baseAnchor);
138
176
  const unrepresentable = [];
139
177
  for (const [anchor, token] of DIRECT_ANCHORS) if (isOverridden(overrides, scope, token)) lines.push(property(anchor, resolveValue(overrides, scope, token), " "));
140
178
  const surfaces = SURFACE_ANCHORS.filter((token) => isOverridden(overrides, scope, token));
@@ -167,17 +205,19 @@ function createThemeSnippet(overrides, scope, name) {
167
205
  if (anchored.has(moved)) continue;
168
206
  unrepresentable.push(`${moved}: ${resolveValue(overrides, scope, moved)}`);
169
207
  }
170
- const footer = unrepresentable.length === 0 ? "" : [
208
+ const notes = [];
209
+ if (base === null) notes.push(`// no manifest is registered for "${scope.theme}"; this call starts from the blank base.`);
210
+ if (unrepresentable.length > 0) notes.push("// createTheme() derives these rather than taking them as anchors, so they are not", "// in the call above. Ship the CSS export alongside it — that one is lossless.", ...unrepresentable.map((entry) => `// ${entry}`));
211
+ const footer = notes.length === 0 ? "" : [
171
212
  "",
172
- "// createTheme() derives these rather than taking them as anchors, so they are not",
173
- "// in the call above. Ship the CSS export alongside it — that one is lossless.",
174
- ...unrepresentable.map((entry) => `// ${entry}`),
213
+ ...notes,
175
214
  ""
176
215
  ].join("\n");
177
216
  return [
178
217
  "import { createTheme } from \"@cueplusplus/ui/theming\";",
218
+ ...baseImport === null ? [] : [baseImport],
179
219
  "",
180
- `export const ${/^[A-Za-z_$][\w$]*$/.test(name) ? name : "theme"} = createTheme({`,
220
+ `export const ${exported} = createTheme({`,
181
221
  ...lines,
182
222
  "});",
183
223
  footer
@@ -1,5 +1,6 @@
1
1
  import { ResolvedMode } from "../system/theme-provider.js";
2
- import { Density, ThemeName } from "@cueplusplus/tokens";
2
+ import { Density } from "@cueplusplus/tokens";
3
+ import { ThemeManifest, ThemeName } from "@cueplusplus/theme-base";
3
4
  //#region src/configurator/_overrides.d.ts
4
5
  /**
5
6
  * The override model behind the theme configurator: what a user may move, where
@@ -68,6 +69,18 @@ interface ThemeScope {
68
69
  theme: ThemeName;
69
70
  /** The mode as resolved — `"system"` never reaches here. */
70
71
  mode: ResolvedMode;
72
+ /**
73
+ * The active theme's manifest, or `null` for the blank base.
74
+ *
75
+ * Required, not optional, and that is the point. {@link baseValue} branches on
76
+ * `manifest === null` to mean *the blank base*; an absent field would take
77
+ * that branch by accident, and normalising `undefined` to `null` would make
78
+ * every caller that has not been updated resolve blank-base colours under a
79
+ * name that is not the blank base — silently, and only visibly wrong to
80
+ * somebody comparing swatches. A compile error naming the property is the
81
+ * right way for a stale caller to find out. Pass `useTheme().manifest`.
82
+ */
83
+ manifest: ThemeManifest | null;
71
84
  }
72
85
  /** Every override the user has made, ready to serialize. Treat as immutable. */
73
86
  interface ThemeOverrides {
@@ -117,15 +130,21 @@ declare function isSafeCssValue(value: string): boolean;
117
130
  /**
118
131
  * The value a token has before the user touches anything.
119
132
  *
120
- * Colours come from the preset for *this mode*; fonts from the preset's own
121
- * override, else the base stack; the multipliers are 1 by definition, because
122
- * what the panel edits is the multiplier and not the ladder underneath it.
133
+ * Everything a theme actually declares comes from `theme-base`'s `resolve()`,
134
+ * which is the one implementation of the layering rule — the theme's block for
135
+ * this mode, the theme's own font override under the base stack — so the panel
136
+ * and the runtime cannot disagree about what "shipped" means. A `null` manifest
137
+ * is the blank base, which resolves to the neutral ramp rather than to nothing:
138
+ * every scope answers, including one naming a theme no manifest was passed for.
139
+ * The multipliers are 1 by definition, because what the panel edits is the
140
+ * multiplier and not the ladder underneath it.
123
141
  *
124
- * @param scope - Theme and mode being edited.
142
+ * @param scope - Theme, mode and manifest being edited.
125
143
  * @param token - A bare token name or a `--cue-*` property.
126
144
  * @returns The shipped value, as a CSS string.
127
145
  * @example
128
- * baseValue({ theme: "terminal", mode: "dark" }, "accent"); // → "hsl(180 100% 50%)"
146
+ * baseValue({ theme: "terminal", mode: "dark", manifest: terminal }, "accent");
147
+ * // → "hsl(180 100% 50%)"
129
148
  */
130
149
  declare function baseValue(scope: ThemeScope, token: string): string;
131
150
  /**
@@ -1,5 +1,6 @@
1
- import { BASE_FONT_STACKS, DEFAULT_DENSITY_LEVEL, DENSITY_RADIUS_SCALE, THEME_COLOR_TOKENS, THEME_PRESETS } from "../theming/_presets.js";
2
- import { THEME_NAME_PATTERN, assertCssValue, serializeSystemModeAliases, serializeThemeBlock, tokenProperty } from "../theming/serialize.js";
1
+ import { THEME_NAME_PATTERN as THEME_NAME_PATTERN$1, assertCssValue, serializeSystemModeAliases, serializeThemeBlock, tokenProperty } from "../theming/serialize.js";
2
+ import { DEFAULT_DENSITY_LEVEL, DENSITY_RADIUS_SCALE, THEME_COLOR_TOKENS } from "../theming/create-theme.js";
3
+ import { resolve } from "@cueplusplus/theme-base";
3
4
  //#region src/configurator/_overrides.ts
4
5
  /**
5
6
  * The override model behind the theme configurator: what a user may move, where
@@ -55,11 +56,10 @@ const OVERRIDE_BANNER = "@cueplusplus/ui — theme configurator overrides";
55
56
  /**
56
57
  * The unitless density multiplier each level publishes.
57
58
  *
58
- * A copy of `packages/tokens/src/density/*.tokens.json`, for the same reason
59
- * `theming/_presets.ts` copies the palettes: the tokens package emits CSS, and a
60
- * value this module has to *multiply* cannot be read out of a stylesheet.
61
- * `test/configurator/overrides.test.ts` re-reads the DTCG sources and fails on
62
- * the first number that has drifted.
59
+ * A copy of `packages/tokens/src/density/*.tokens.json`: the tokens package
60
+ * emits CSS, and a value this module has to *multiply* cannot be read out of a
61
+ * stylesheet. `test/configurator/overrides.test.ts` re-reads the DTCG sources
62
+ * and fails on the first number that has drifted.
63
63
  */
64
64
  const DENSITY_FACTOR = {
65
65
  "ultra-compact": .85,
@@ -120,13 +120,21 @@ function tokenKind(token) {
120
120
  function scopeKey(scope) {
121
121
  return `${scope.theme}/${scope.mode}`;
122
122
  }
123
- /** The inverse of {@link scopeKey}, rejecting anything that would not be safe in a selector. */
123
+ /**
124
+ * The inverse of {@link scopeKey}, rejecting anything that would not be safe in
125
+ * a selector.
126
+ *
127
+ * A bare `{ theme, mode }` rather than a {@link ThemeScope}: a storage key
128
+ * carries a *name*, and this module has no registry to look a manifest up in.
129
+ * Both callers below want only the selector halves. A caller that needs the
130
+ * manifest attaches it from the registry it holds.
131
+ */
124
132
  function parseScopeKey(key) {
125
133
  const slash = key.lastIndexOf("/");
126
134
  if (slash === -1) return null;
127
135
  const theme = key.slice(0, slash);
128
136
  const mode = key.slice(slash + 1);
129
- if (!THEME_NAME_PATTERN.test(theme)) return null;
137
+ if (!THEME_NAME_PATTERN$1.test(theme)) return null;
130
138
  if (mode !== "dark" && mode !== "light") return null;
131
139
  return {
132
140
  theme,
@@ -157,23 +165,28 @@ function isSafeCssValue(value) {
157
165
  /**
158
166
  * The value a token has before the user touches anything.
159
167
  *
160
- * Colours come from the preset for *this mode*; fonts from the preset's own
161
- * override, else the base stack; the multipliers are 1 by definition, because
162
- * what the panel edits is the multiplier and not the ladder underneath it.
168
+ * Everything a theme actually declares comes from `theme-base`'s `resolve()`,
169
+ * which is the one implementation of the layering rule — the theme's block for
170
+ * this mode, the theme's own font override under the base stack — so the panel
171
+ * and the runtime cannot disagree about what "shipped" means. A `null` manifest
172
+ * is the blank base, which resolves to the neutral ramp rather than to nothing:
173
+ * every scope answers, including one naming a theme no manifest was passed for.
174
+ * The multipliers are 1 by definition, because what the panel edits is the
175
+ * multiplier and not the ladder underneath it.
163
176
  *
164
- * @param scope - Theme and mode being edited.
177
+ * @param scope - Theme, mode and manifest being edited.
165
178
  * @param token - A bare token name or a `--cue-*` property.
166
179
  * @returns The shipped value, as a CSS string.
167
180
  * @example
168
- * baseValue({ theme: "terminal", mode: "dark" }, "accent"); // → "hsl(180 100% 50%)"
181
+ * baseValue({ theme: "terminal", mode: "dark", manifest: terminal }, "accent");
182
+ * // → "hsl(180 100% 50%)"
169
183
  */
170
184
  function baseValue(scope, token) {
171
185
  const property = tokenProperty(token);
172
186
  const name = property.slice(6);
173
- const preset = THEME_PRESETS[scope.theme];
174
187
  switch (tokenKind(property)) {
175
- case "color": return ((scope.mode === "light" ? preset?.light : preset?.dark) ?? preset?.dark)?.[name] ?? "";
176
- case "font": return preset?.fonts[name] ?? BASE_FONT_STACKS[name] ?? "";
188
+ case "color": return resolve(scope.manifest, { mode: scope.mode }).colors[name] ?? "";
189
+ case "font": return resolve(scope.manifest, { mode: scope.mode }).fonts[name] ?? "";
177
190
  case "scaled":
178
191
  case "inline": return "1";
179
192
  case "flat": return BASE_TRACKING_LABEL;
@@ -56,11 +56,16 @@ const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
56
56
  * <ThemeConfigurator position="docked-right" onChange={(tokens) => report(tokens)} />
57
57
  */
58
58
  function ThemeConfigurator({ position: positionProp, defaultPosition = "floating", onPositionChange, defaultCollapsed = false, storageKey = DEFAULT_CONFIGURATOR_STORAGE_KEY, onChange, zoomShortcuts = true, title = "Theme", className }) {
59
- const { theme, resolvedMode } = useTheme();
59
+ const { theme, resolvedMode, manifest } = useTheme();
60
60
  const scope = React.useMemo(() => ({
61
61
  theme,
62
- mode: resolvedMode
63
- }), [theme, resolvedMode]);
62
+ mode: resolvedMode,
63
+ manifest
64
+ }), [
65
+ theme,
66
+ resolvedMode,
67
+ manifest
68
+ ]);
64
69
  const [snapshot, setSnapshot] = React.useState(() => {
65
70
  const initial = defaultSnapshot();
66
71
  return {