@cueplusplus/ui 0.1.0 → 0.2.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 (83) hide show
  1. package/CHANGELOG.md +684 -0
  2. package/dist/brand/_chassis.d.ts +54 -0
  3. package/dist/brand/_chassis.js +81 -0
  4. package/dist/brand/_geometry.js +41 -0
  5. package/dist/brand/cue-logotype.d.ts +29 -0
  6. package/dist/brand/cue-logotype.js +60 -0
  7. package/dist/brand/cue-mark.d.ts +30 -0
  8. package/dist/brand/cue-mark.js +61 -0
  9. package/dist/brand/index.d.ts +5 -0
  10. package/dist/brand/plussie.d.ts +67 -0
  11. package/dist/brand/plussie.js +125 -0
  12. package/dist/chat/agent-pile.js +1 -1
  13. package/dist/chat/ask-box.js +2 -2
  14. package/dist/chat/delegation-card.js +1 -1
  15. package/dist/chat/message.js +2 -2
  16. package/dist/chrome/_edge-scroller.d.ts +45 -0
  17. package/dist/chrome/_edge-scroller.js +138 -0
  18. package/dist/chrome/_glyphs.js +19 -1
  19. package/dist/chrome/_tabs-scroll.d.ts +12 -7
  20. package/dist/chrome/_tabs-scroll.js +12 -7
  21. package/dist/chrome/app-bar.d.ts +103 -0
  22. package/dist/chrome/app-bar.js +175 -0
  23. package/dist/chrome/app-shell.d.ts +125 -0
  24. package/dist/chrome/app-shell.js +167 -0
  25. package/dist/chrome/footer.d.ts +75 -0
  26. package/dist/chrome/footer.js +68 -0
  27. package/dist/chrome/index.d.ts +5 -1
  28. package/dist/chrome/index.js +4 -1
  29. package/dist/chrome/navigation-menu.js +1 -1
  30. package/dist/chrome/page-shell.d.ts +1 -1
  31. package/dist/chrome/page-shell.js +15 -5
  32. package/dist/chrome/segmented-control.d.ts +19 -0
  33. package/dist/chrome/segmented-control.js +40 -21
  34. package/dist/chrome/tabs.js +21 -65
  35. package/dist/chrome/toolbar.d.ts +22 -0
  36. package/dist/chrome/toolbar.js +26 -5
  37. package/dist/configurator/_overrides.js +4 -2
  38. package/dist/configurator/configurator.js +1 -1
  39. package/dist/configurator/panel-sections.js +10 -4
  40. package/dist/dmx/channel-matrix.js +1 -1
  41. package/dist/forms/otp-field.js +1 -1
  42. package/dist/index.d.ts +15 -6
  43. package/dist/index.js +15 -9
  44. package/dist/instruments/data-table.js +1 -1
  45. package/dist/layout/carousel.js +1 -1
  46. package/dist/layout/container.d.ts +1 -1
  47. package/dist/layout/container.js +6 -3
  48. package/dist/layout/index.d.ts +3 -3
  49. package/dist/layout/index.js +3 -3
  50. package/dist/layout/pagination.js +1 -1
  51. package/dist/layout/sidebar.d.ts +23 -3
  52. package/dist/layout/sidebar.js +18 -7
  53. package/dist/midi/spectrum-visualizer.js +1 -1
  54. package/dist/midi/timeline-ruler.js +1 -1
  55. package/dist/overlays/command-palette.js +1 -1
  56. package/dist/overlays/dialog.js +1 -1
  57. package/dist/overlays/dropdown-menu.js +2 -2
  58. package/dist/overlays/hover-card.js +1 -1
  59. package/dist/overlays/index.js +1 -1
  60. package/dist/overlays/popover.js +1 -1
  61. package/dist/overlays/sheet.js +1 -1
  62. package/dist/overlays/toast.js +1 -1
  63. package/dist/primitives/index.d.ts +1 -1
  64. package/dist/primitives/index.js +1 -1
  65. package/dist/primitives/status-dot.d.ts +7 -0
  66. package/dist/primitives/status-dot.js +8 -1
  67. package/dist/styles.css +47 -1
  68. package/dist/system/density.d.ts +4 -1
  69. package/dist/system/density.js +12 -4
  70. package/dist/system/index.d.ts +2 -2
  71. package/dist/system/portal.d.ts +5 -5
  72. package/dist/system/portal.js +11 -6
  73. package/dist/system/prepaint.d.ts +15 -8
  74. package/dist/system/prepaint.js +26 -9
  75. package/dist/system/theme-provider.d.ts +36 -10
  76. package/dist/system/theme-provider.js +56 -18
  77. package/dist/system/use-density.d.ts +3 -3
  78. package/dist/system/use-density.js +16 -6
  79. package/dist/system/use-theme.d.ts +4 -2
  80. package/dist/system/use-theme.js +4 -2
  81. package/dist/theming/_presets.js +80 -5
  82. package/dist/theming/create-theme.js +13 -4
  83. package/package.json +4 -3
@@ -1,8 +1,8 @@
1
1
  "use client";
2
2
  import { cn } from "../lib/cn.js";
3
+ import { useComposedRefs } from "../lib/compose.js";
3
4
  import { useCuePortalProps } from "../system/portal.js";
4
5
  import { overlayDescriptionClasses, overlayPopupTransitionClasses, overlayPositionerClasses, overlaySurfaceClasses, overlayTitleClasses } from "./_surface.js";
5
- import { useComposedRefs } from "../lib/compose.js";
6
6
  import { useCloseOnWheel } from "./_wheel.js";
7
7
  import * as React from "react";
8
8
  import { jsx } from "react/jsx-runtime";
@@ -1,10 +1,10 @@
1
1
  "use client";
2
2
  import { cn } from "../lib/cn.js";
3
3
  import { cva } from "../lib/cva.js";
4
+ import { CrossGlyph } from "./_glyphs.js";
4
5
  import { useCuePortalProps } from "../system/portal.js";
5
6
  import { overlayDescriptionClasses, overlayFooterClasses, overlaySurfaceClasses, overlayTitleClasses, scrimClasses } from "./_surface.js";
6
7
  import { IconButton } from "../primitives/icon-button.js";
7
- import { CrossGlyph } from "./_glyphs.js";
8
8
  import * as React from "react";
9
9
  import { jsx, jsxs } from "react/jsx-runtime";
10
10
  import { Dialog } from "@base-ui/react/dialog";
@@ -1,8 +1,8 @@
1
1
  "use client";
2
2
  import { cn } from "../lib/cn.js";
3
+ import { CrossGlyph } from "./_glyphs.js";
3
4
  import { useCuePortalProps } from "../system/portal.js";
4
5
  import { overlaySurfaceClasses } from "./_surface.js";
5
- import { CrossGlyph } from "./_glyphs.js";
6
6
  import * as React from "react";
7
7
  import { jsx, jsxs } from "react/jsx-runtime";
8
8
  import { Toast } from "@base-ui/react/toast";
@@ -1,10 +1,10 @@
1
+ import { Separator, SeparatorProps } from "./separator.js";
1
2
  import { Button, ButtonProps, buttonVariants } from "./button.js";
2
3
  import { StatusDot, StatusDotProps, StatusTone } from "./status-dot.js";
3
4
  import { Avatar, AvatarGroup, AvatarGroupProps, AvatarProps, AvatarSize } from "./avatar.js";
4
5
  import { Chip, ChipProps, chipVariants } from "./chip.js";
5
6
  import { IconButton, IconButtonProps } from "./icon-button.js";
6
7
  import { Kbd, KbdProps } from "./kbd.js";
7
- import { Separator, SeparatorProps } from "./separator.js";
8
8
  import { Skeleton, SkeletonProps } from "./skeleton.js";
9
9
  import { Spinner, SpinnerProps } from "./spinner.js";
10
10
  export { Avatar, AvatarGroup, type AvatarGroupProps, type AvatarProps, type AvatarSize, Button, type ButtonProps, Chip, type ChipProps, IconButton, type IconButtonProps, Kbd, type KbdProps, Separator, type SeparatorProps, Skeleton, type SkeletonProps, Spinner, type SpinnerProps, StatusDot, type StatusDotProps, type StatusTone, buttonVariants, chipVariants };
@@ -1,10 +1,10 @@
1
1
  import { Button, buttonVariants } from "./button.js";
2
2
  import { Chip, chipVariants } from "./chip.js";
3
3
  import { Spinner } from "./spinner.js";
4
+ import { Separator } from "./separator.js";
4
5
  import { IconButton } from "./icon-button.js";
5
6
  import { StatusDot } from "./status-dot.js";
6
7
  import { Avatar, AvatarGroup } from "./avatar.js";
7
8
  import { Kbd } from "./kbd.js";
8
- import { Separator } from "./separator.js";
9
9
  import { Skeleton } from "./skeleton.js";
10
10
  export { Avatar, AvatarGroup, Button, Chip, IconButton, Kbd, Separator, Skeleton, Spinner, StatusDot, buttonVariants, chipVariants };
@@ -32,6 +32,13 @@ interface StatusDotProps extends Omit<React.ComponentPropsWithoutRef<"span">, "c
32
32
  * component always emits an `sr-only` label inside a `role="status"` wrapper,
33
33
  * so the state is announced as well as shown (WCAG 1.4.1).
34
34
  *
35
+ * The wrapper is `relative` for that label's sake and no other reason.
36
+ * `.sr-only` is `position: absolute`; without a positioned ancestor its
37
+ * containing block is the initial one, so its scrollable overflow lands on
38
+ * `<html>` rather than on whatever scroller it is nested in. One dot is
39
+ * harmless and a table of two thousand rows is a document several thousand
40
+ * pixels taller than anything visible on it.
41
+ *
35
42
  * Static markup — no `"use client"`.
36
43
  *
37
44
  * @example
@@ -20,6 +20,13 @@ const toRem = (px) => `${px / 16}rem`;
20
20
  * component always emits an `sr-only` label inside a `role="status"` wrapper,
21
21
  * so the state is announced as well as shown (WCAG 1.4.1).
22
22
  *
23
+ * The wrapper is `relative` for that label's sake and no other reason.
24
+ * `.sr-only` is `position: absolute`; without a positioned ancestor its
25
+ * containing block is the initial one, so its scrollable overflow lands on
26
+ * `<html>` rather than on whatever scroller it is nested in. One dot is
27
+ * harmless and a table of two thousand rows is a document several thousand
28
+ * pixels taller than anything visible on it.
29
+ *
23
30
  * Static markup — no `"use client"`.
24
31
  *
25
32
  * @example
@@ -31,7 +38,7 @@ const StatusDot = React.forwardRef(function StatusDot({ className, tone, pulse =
31
38
  return /* @__PURE__ */ jsxs("span", {
32
39
  ref,
33
40
  role: "status",
34
- className: cn("inline-flex shrink-0 items-center justify-center", className),
41
+ className: cn("relative inline-flex shrink-0 items-center justify-center", className),
35
42
  ...elementProps,
36
43
  children: [/* @__PURE__ */ jsx("span", {
37
44
  "aria-hidden": "true",
package/dist/styles.css CHANGED
@@ -14,6 +14,52 @@
14
14
 
15
15
  @source "./";
16
16
 
17
- @custom-variant density-ultra (&:where([data-density="ultra-compact"], [data-density="ultra-compact"] *));
17
+ /* One variant per rung of the density ladder, each named after the rung it
18
+ * matches — so `density-large:` means the level a consumer writes as
19
+ * `density="large"`, and cannot drift from it.
20
+ *
21
+ * `density-ultra:` is gone with the five-rung rename of 2026-08-19: with an
22
+ * `ultra-large` rung in the ladder, "ultra" no longer names one level. Its
23
+ * replacement is `density-ultra-compact:`. `density-large:` survives the rename
24
+ * as a name and changes meaning with it — it matched the ladder's top rung
25
+ * before, and the ladder now has two rungs above that one. */
26
+ @custom-variant density-ultra-compact (&:where([data-density="ultra-compact"], [data-density="ultra-compact"] *));
27
+ @custom-variant density-compact (&:where([data-density="compact"], [data-density="compact"] *));
28
+ @custom-variant density-normal (&:where([data-density="normal"], [data-density="normal"] *));
18
29
  @custom-variant density-large (&:where([data-density="large"], [data-density="large"] *));
30
+ @custom-variant density-ultra-large (&:where([data-density="ultra-large"], [data-density="ultra-large"] *));
19
31
  @custom-variant mode-light (&:where([data-mode="light"], [data-mode="light"] *));
32
+
33
+ /* The one keyframe this library's core needs, and the reason it cannot be a
34
+ * utility: `<Plussie idle />` blinks, and Tailwind has no `animate-*` for a
35
+ * two-frame squash about an SVG group's own centre.
36
+ *
37
+ * Same shape as the pulse in `flow.css`: a bespoke class, the keyframes beside
38
+ * it, and a `prefers-reduced-motion` block that switches it off. `fill-box`
39
+ * puts the transform origin at the eye's own centre rather than the SVG's
40
+ * corner, which is what makes the eye shut across its middle instead of sliding
41
+ * up. The class does nothing unless the prop asks for it — every other Plussie
42
+ * on the page is still. */
43
+ @keyframes cue-plussie-blink {
44
+ 0%,
45
+ 93%,
46
+ 100% {
47
+ transform: scaleY(1);
48
+ }
49
+ 95.5%,
50
+ 96.5% {
51
+ transform: scaleY(0.05);
52
+ }
53
+ }
54
+
55
+ .cue-plussie-idle {
56
+ transform-box: fill-box;
57
+ transform-origin: center;
58
+ animation: cue-plussie-blink 5.2s ease-in-out infinite;
59
+ }
60
+
61
+ @media (prefers-reduced-motion: reduce) {
62
+ .cue-plussie-idle {
63
+ animation: none;
64
+ }
65
+ }
@@ -2,7 +2,10 @@ import * as React from "react";
2
2
  import { Density } from "@cueplusplus/tokens";
3
3
  //#region src/system/density.d.ts
4
4
  interface DensityProps {
5
- /** Level to apply to this subtree: `ultra-compact`, `normal` or `large`. */
5
+ /**
6
+ * Level to apply to this subtree: `ultra-compact`, `compact`, `normal`,
7
+ * `large` or `ultra-large`.
8
+ */
6
9
  density: Density;
7
10
  /** Subtree that should render at `density`. */
8
11
  children: React.ReactNode;
@@ -2,13 +2,21 @@
2
2
  import * as React from "react";
3
3
  import { jsx } from "react/jsx-runtime";
4
4
  //#region src/system/density.tsx
5
- /** Density level every unstamped subtree falls back to. */
6
- const DEFAULT_DENSITY = "normal";
7
- /** The three levels, as a runtime array for validating persisted input. */
5
+ /**
6
+ * Density level every unstamped subtree falls back to.
7
+ *
8
+ * `compact`, which is the second rung and not the middle one. This is console
9
+ * software: the ladder is symmetric about `normal` for the sake of its names,
10
+ * but the level a screen gets when nobody chose is the dense one.
11
+ */
12
+ const DEFAULT_DENSITY = "compact";
13
+ /** The five levels, as a runtime array for validating persisted input. */
8
14
  const DENSITY_LEVELS = [
9
15
  "ultra-compact",
16
+ "compact",
10
17
  "normal",
11
- "large"
18
+ "large",
19
+ "ultra-large"
12
20
  ];
13
21
  /**
14
22
  * Nearest ambient density. `null` means "nothing has stamped a level yet", which
@@ -4,5 +4,5 @@ import { CuePortalFrame, CuePortalFrameProps, useCuePortalProps } from "./portal
4
4
  import { DEFAULT_STORAGE_KEY, PersistedPreferences, prepaintScript } from "./prepaint.js";
5
5
  import { ControlSize, useControlHeight, useDensity } from "./use-density.js";
6
6
  import { useTheme } from "./use-theme.js";
7
- import { Density as DensityLevel, Mode, ThemeName } from "@cueplusplus/tokens";
8
- export { type ControlSize, CuePortalFrame, type CuePortalFrameProps, DEFAULT_STORAGE_KEY, Density, type DensityLevel, type DensityProps, type Mode, type PersistedPreferences, type ResolvedMode, type ThemeContextValue, type ThemeName, ThemeProvider, type ThemeProviderProps, prepaintScript, useControlHeight, useCuePortalProps, useDensity, useTheme };
7
+ import { Density as DensityLevel, FontName, Mode, ThemeName } from "@cueplusplus/tokens";
8
+ export { type ControlSize, CuePortalFrame, type CuePortalFrameProps, DEFAULT_STORAGE_KEY, Density, type DensityLevel, type DensityProps, type FontName, type Mode, type PersistedPreferences, type ResolvedMode, type ThemeContextValue, type ThemeName, ThemeProvider, type ThemeProviderProps, prepaintScript, useControlHeight, useCuePortalProps, useDensity, useTheme };
@@ -2,11 +2,11 @@ import * as React from "react";
2
2
  //#region src/system/portal.d.ts
3
3
  /**
4
4
  * Props to spread onto a Base UI `Portal` part so the portal root inherits the
5
- * caller's theme, density and mode.
5
+ * caller's theme, density, font pairing and mode.
6
6
  *
7
7
  * Returns a `container` element appended to `document.body` and stamped with
8
- * `data-theme` / `data-density` / `data-mode` (plus `--cue-font-scale`) copied
9
- * from the nearest provider and density island. The container is
8
+ * `data-theme` / `data-density` / `data-font` / `data-mode` (plus
9
+ * `--cue-font-scale`) copied from the nearest provider and density island. The container is
10
10
  * `display: contents`, so it changes no layout and creates no containing block —
11
11
  * fixed-position overlay content behaves exactly as if it were a direct child of
12
12
  * `<body>`.
@@ -38,8 +38,8 @@ interface CuePortalFrameProps {
38
38
  * chosen (a third-party overlay, or content already portaled by something else).
39
39
  *
40
40
  * Wraps `children` in a `display: contents` element carrying the same
41
- * `data-theme` / `data-density` / `data-mode` triple and `--cue-font-scale` as
42
- * {@link useCuePortalProps}. Prefer the hook — a stamped container costs one
41
+ * `data-theme` / `data-density` / `data-font` / `data-mode` stamp and
42
+ * `--cue-font-scale` as {@link useCuePortalProps}. Prefer the hook — a stamped container costs one
43
43
  * element per overlay instead of one per render tree — and reach for this only
44
44
  * when the container prop is not available.
45
45
  *
@@ -4,6 +4,7 @@ import { DensityContext } from "./density.js";
4
4
  import { FontScaleContext, ThemeContext } from "./theme-provider.js";
5
5
  import * as React from "react";
6
6
  import { jsx } from "react/jsx-runtime";
7
+ import { DEFAULT_FONT } from "@cueplusplus/tokens";
7
8
  //#region src/system/portal.tsx
8
9
  const DEFAULT_THEME = "cue";
9
10
  const DEFAULT_RESOLVED_MODE = "dark";
@@ -23,18 +24,19 @@ function usePortalStamp() {
23
24
  const fontScale = React.useContext(FontScaleContext);
24
25
  return {
25
26
  theme: theme?.theme ?? DEFAULT_THEME,
26
- density: island ?? theme?.density ?? "normal",
27
+ density: island ?? theme?.density ?? "compact",
28
+ font: theme?.font ?? DEFAULT_FONT,
27
29
  mode: theme?.resolvedMode ?? DEFAULT_RESOLVED_MODE,
28
30
  fontScale
29
31
  };
30
32
  }
31
33
  /**
32
34
  * Props to spread onto a Base UI `Portal` part so the portal root inherits the
33
- * caller's theme, density and mode.
35
+ * caller's theme, density, font pairing and mode.
34
36
  *
35
37
  * Returns a `container` element appended to `document.body` and stamped with
36
- * `data-theme` / `data-density` / `data-mode` (plus `--cue-font-scale`) copied
37
- * from the nearest provider and density island. The container is
38
+ * `data-theme` / `data-density` / `data-font` / `data-mode` (plus
39
+ * `--cue-font-scale`) copied from the nearest provider and density island. The container is
38
40
  * `display: contents`, so it changes no layout and creates no containing block —
39
41
  * fixed-position overlay content behaves exactly as if it were a direct child of
40
42
  * `<body>`.
@@ -75,6 +77,7 @@ function useCuePortalProps() {
75
77
  container,
76
78
  stamp.theme,
77
79
  stamp.density,
80
+ stamp.font,
78
81
  stamp.mode,
79
82
  stamp.fontScale
80
83
  ]);
@@ -84,6 +87,7 @@ function useCuePortalProps() {
84
87
  function applyStamp(element, stamp) {
85
88
  element.setAttribute("data-theme", stamp.theme);
86
89
  element.setAttribute("data-density", stamp.density);
90
+ element.setAttribute("data-font", stamp.font);
87
91
  element.setAttribute("data-mode", stamp.mode);
88
92
  if (stamp.fontScale === null) element.style.removeProperty(FONT_SCALE_PROPERTY);
89
93
  else element.style.setProperty(FONT_SCALE_PROPERTY, String(stamp.fontScale));
@@ -93,8 +97,8 @@ function applyStamp(element, stamp) {
93
97
  * chosen (a third-party overlay, or content already portaled by something else).
94
98
  *
95
99
  * Wraps `children` in a `display: contents` element carrying the same
96
- * `data-theme` / `data-density` / `data-mode` triple and `--cue-font-scale` as
97
- * {@link useCuePortalProps}. Prefer the hook — a stamped container costs one
100
+ * `data-theme` / `data-density` / `data-font` / `data-mode` stamp and
101
+ * `--cue-font-scale` as {@link useCuePortalProps}. Prefer the hook — a stamped container costs one
98
102
  * element per overlay instead of one per render tree — and reach for this only
99
103
  * when the container prop is not available.
100
104
  *
@@ -112,6 +116,7 @@ function CuePortalFrame({ children, className, style }) {
112
116
  "data-cue-portal": "",
113
117
  "data-theme": stamp.theme,
114
118
  "data-density": stamp.density,
119
+ "data-font": stamp.font,
115
120
  "data-mode": stamp.mode,
116
121
  className,
117
122
  style: frameStyle,
@@ -1,4 +1,4 @@
1
- import { ThemeName } from "@cueplusplus/tokens";
1
+ import { Density, FontName, ThemeName } from "@cueplusplus/tokens";
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";
@@ -10,17 +10,19 @@ interface PersistedPreferences {
10
10
  density?: string;
11
11
  /** Last mode the user picked, including the literal `"system"`. */
12
12
  mode?: string;
13
+ /** Last font pairing the user picked. */
14
+ font?: string;
13
15
  }
14
16
  /**
15
17
  * Build the blocking inline script that stamps `data-theme`, `data-density`,
16
- * `data-mode` and `color-scheme` on `<html>` from localStorage before the first
17
- * paint.
18
+ * `data-font`, `data-mode` and `color-scheme` on `<html>` from localStorage
19
+ * before the first paint.
18
20
  *
19
21
  * Render it as `<script dangerouslySetInnerHTML={{ __html: prepaintScript() }} />`
20
22
  * in `<head>`, above everything else. The output never contains `</script>` and
21
23
  * never throws: private-mode localStorage failures degrade to the dark-first
22
- * defaults (`cue` / `normal` / `dark`), which are also what bare `:root` in
23
- * `@cueplusplus/tokens/theme.css` already paints.
24
+ * defaults (`cue` / `compact` / `system` / `dark`), which are also what bare
25
+ * `:root` in `@cueplusplus/tokens/theme.css` already paints.
24
26
  *
25
27
  * @param storageKey - localStorage key to read. Must match the `storageKey`
26
28
  * passed to `<ThemeProvider>`. Defaults to {@link DEFAULT_STORAGE_KEY}.
@@ -28,11 +30,16 @@ interface PersistedPreferences {
28
30
  * Must match the `theme` passed to `<ThemeProvider>`, or the first paint
29
31
  * dresses `<html>` in one preset while the app renders in another. Defaults to
30
32
  * `"cue"`.
33
+ * @param defaultDensity - Density level to stamp for a visitor with nothing
34
+ * persisted yet. Same rule as `defaultTheme`: it must match the `density`
35
+ * passed to `<ThemeProvider>` or the first frame is drawn at one rung and
36
+ * every frame after it at another. Defaults to `"compact"`, the library's own
37
+ * fallback, which is what an app that passes no `density` renders at.
31
38
  * @returns One line of JavaScript, safe to inline verbatim.
32
39
  * @example
33
- * // An app whose provider is <ThemeProvider theme="terminal">:
34
- * prepaintScript(DEFAULT_STORAGE_KEY, "terminal");
40
+ * // An app whose provider is <ThemeProvider theme="terminal" density="normal">:
41
+ * prepaintScript(DEFAULT_STORAGE_KEY, "terminal", "normal");
35
42
  */
36
- declare function prepaintScript(storageKey?: string, defaultTheme?: ThemeName): string;
43
+ declare function prepaintScript(storageKey?: string, defaultTheme?: ThemeName, defaultDensity?: Density, defaultFont?: FontName): string;
37
44
  //#endregion
38
45
  export { DEFAULT_STORAGE_KEY, PersistedPreferences, prepaintScript };
@@ -1,23 +1,31 @@
1
+ import { DEFAULT_DENSITY, DEFAULT_FONT, DENSITIES, FONTS } from "@cueplusplus/tokens";
1
2
  //#region src/system/prepaint.ts
3
+ /**
4
+ * Pre-paint stamping. Server-safe by design: no `"use client"`, no React, no
5
+ * DOM access at module scope — `app/layout.tsx` calls `prepaintScript()` on the
6
+ * server and inlines the result before hydration.
7
+ */
2
8
  /** localStorage key the ThemeProvider and the pre-paint script share by default. */
3
9
  const DEFAULT_STORAGE_KEY = "cue-ui";
10
+ /** `<` and `>` escaped, so no value can close the host `<script>` element. */
11
+ const scriptSafe = (json) => json.replace(/</g, "\\u003c").replace(/>/g, "\\u003e");
4
12
  /**
5
13
  * Embed a string in JavaScript source that will itself be embedded in HTML.
6
14
  * `<` becomes `\u003c` so no value can close the host `<script>` element.
7
15
  */
8
16
  function jsStringLiteral(value) {
9
- return JSON.stringify(value).replace(/</g, "\\u003c").replace(/>/g, "\\u003e");
17
+ return scriptSafe(JSON.stringify(value));
10
18
  }
11
19
  /**
12
20
  * Build the blocking inline script that stamps `data-theme`, `data-density`,
13
- * `data-mode` and `color-scheme` on `<html>` from localStorage before the first
14
- * paint.
21
+ * `data-font`, `data-mode` and `color-scheme` on `<html>` from localStorage
22
+ * before the first paint.
15
23
  *
16
24
  * Render it as `<script dangerouslySetInnerHTML={{ __html: prepaintScript() }} />`
17
25
  * in `<head>`, above everything else. The output never contains `<\/script>` and
18
26
  * never throws: private-mode localStorage failures degrade to the dark-first
19
- * defaults (`cue` / `normal` / `dark`), which are also what bare `:root` in
20
- * `@cueplusplus/tokens/theme.css` already paints.
27
+ * defaults (`cue` / `compact` / `system` / `dark`), which are also what bare
28
+ * `:root` in `@cueplusplus/tokens/theme.css` already paints.
21
29
  *
22
30
  * @param storageKey - localStorage key to read. Must match the `storageKey`
23
31
  * passed to `<ThemeProvider>`. Defaults to {@link DEFAULT_STORAGE_KEY}.
@@ -25,13 +33,22 @@ function jsStringLiteral(value) {
25
33
  * Must match the `theme` passed to `<ThemeProvider>`, or the first paint
26
34
  * dresses `<html>` in one preset while the app renders in another. Defaults to
27
35
  * `"cue"`.
36
+ * @param defaultDensity - Density level to stamp for a visitor with nothing
37
+ * persisted yet. Same rule as `defaultTheme`: it must match the `density`
38
+ * passed to `<ThemeProvider>` or the first frame is drawn at one rung and
39
+ * every frame after it at another. Defaults to `"compact"`, the library's own
40
+ * fallback, which is what an app that passes no `density` renders at.
28
41
  * @returns One line of JavaScript, safe to inline verbatim.
29
42
  * @example
30
- * // An app whose provider is <ThemeProvider theme="terminal">:
31
- * prepaintScript(DEFAULT_STORAGE_KEY, "terminal");
43
+ * // An app whose provider is <ThemeProvider theme="terminal" density="normal">:
44
+ * prepaintScript(DEFAULT_STORAGE_KEY, "terminal", "normal");
32
45
  */
33
- function prepaintScript(storageKey = DEFAULT_STORAGE_KEY, defaultTheme = "cue") {
34
- return `!function(){try{var k=${jsStringLiteral(storageKey)},e=document.documentElement,s=null;try{var r=localStorage.getItem(k);s=r?JSON.parse(r):null}catch(_){}s=s&&typeof s=="object"?s:{};var t=typeof s.theme=="string"&&s.theme?s.theme:${jsStringLiteral(defaultTheme)};var d=["ultra-compact","normal","large"].indexOf(s.density)>-1?s.density:"normal";var m=["dark","light","system"].indexOf(s.mode)>-1?s.mode:"dark";if(m==="system")m=typeof matchMedia=="function"&&matchMedia("(prefers-color-scheme: light)").matches?"light":"dark";e.setAttribute("data-theme",t);e.setAttribute("data-density",d);e.setAttribute("data-mode",m);e.style.colorScheme=m}catch(_){}}()`;
46
+ function prepaintScript(storageKey = DEFAULT_STORAGE_KEY, defaultTheme = "cue", defaultDensity = DEFAULT_DENSITY, defaultFont = DEFAULT_FONT) {
47
+ const key = jsStringLiteral(storageKey);
48
+ const fallbackTheme = jsStringLiteral(defaultTheme);
49
+ const fallbackDensity = jsStringLiteral(defaultDensity);
50
+ const fallbackFont = jsStringLiteral(defaultFont);
51
+ return `!function(){try{var k=${key},e=document.documentElement,s=null;try{var r=localStorage.getItem(k);s=r?JSON.parse(r):null}catch(_){}s=s&&typeof s=="object"?s:{};var t=typeof s.theme=="string"&&s.theme?s.theme:${fallbackTheme};var d=${scriptSafe(JSON.stringify([...DENSITIES]))}.indexOf(s.density)>-1?s.density:${fallbackDensity};var f=${scriptSafe(JSON.stringify([...FONTS]))}.indexOf(s.font)>-1?s.font:${fallbackFont};var m=["dark","light","system"].indexOf(s.mode)>-1?s.mode:"dark";if(m==="system")m=typeof matchMedia=="function"&&matchMedia("(prefers-color-scheme: light)").matches?"light":"dark";e.setAttribute("data-theme",t);e.setAttribute("data-density",d);e.setAttribute("data-font",f);e.setAttribute("data-mode",m);e.style.colorScheme=m}catch(_){}}()`;
35
52
  }
36
53
  //#endregion
37
54
  export { DEFAULT_STORAGE_KEY, prepaintScript };
@@ -1,5 +1,5 @@
1
1
  import * as React from "react";
2
- import { Density, Mode, ThemeName } from "@cueplusplus/tokens";
2
+ import { Density, FontName, Mode, ThemeName } from "@cueplusplus/tokens";
3
3
  //#region src/system/theme-provider.d.ts
4
4
  /** A mode that has been resolved to an actual palette — `"system"` never survives this far. */
5
5
  type ResolvedMode = "dark" | "light";
@@ -11,6 +11,8 @@ interface ThemeContextValue {
11
11
  density: Density;
12
12
  /** Mode as chosen, including the literal `"system"`. */
13
13
  mode: Mode;
14
+ /** Active font pairing. */
15
+ font: FontName;
14
16
  /** Mode after resolving `"system"` against `prefers-color-scheme`. */
15
17
  resolvedMode: ResolvedMode;
16
18
  /** Switch theme and persist the new preference triple. */
@@ -19,18 +21,36 @@ interface ThemeContextValue {
19
21
  setDensity: (density: Density) => void;
20
22
  /** Switch mode (`"system"` included) and persist the new preference triple. */
21
23
  setMode: (mode: Mode) => void;
24
+ /** Switch the font pairing and persist the new preference. */
25
+ setFont: (font: FontName) => void;
22
26
  }
23
27
  interface ThemeProviderProps {
24
28
  /** Initial theme preset. Defaults to `"cue"`. Changing it after mount adopts the new value. */
25
29
  theme?: ThemeName;
26
- /** Initial density level. Defaults to `"normal"`. */
30
+ /** Initial density level. Defaults to `"compact"`. */
27
31
  density?: Density;
28
32
  /** Initial mode. Defaults to `"dark"`; `"system"` tracks `prefers-color-scheme`. */
29
33
  mode?: Mode;
34
+ /**
35
+ * Initial font pairing. Defaults to `"system"` — the platform's own faces,
36
+ * nothing downloaded, and the theme keeps whatever monospace it authored.
37
+ *
38
+ * A pairing that needs delivering is a set of *names*: this library ships no
39
+ * font files, and a pairing nobody delivers falls through its stack to the
40
+ * platform rather than failing. See `FONT_PAIRINGS[font].faces` for the
41
+ * custom properties an app assigns to make one resolve.
42
+ *
43
+ * **Root-level only.** There is no font island: a nested provider forwards
44
+ * this axis to the root, ignores this prop, and its `setFont` drives the root.
45
+ * A page that changed face halfway down is a page with a bug, and a specimen
46
+ * that genuinely wants one — a picker row, a docs page showing all eight —
47
+ * needs nothing from this library but `data-font` on a `<div>`.
48
+ */
49
+ font?: FontName;
30
50
  /** Multiplier on every text token, published as `--cue-font-scale`. Defaults to `1`. */
31
51
  fontScale?: number;
32
52
  /**
33
- * localStorage key holding `{ theme, density, mode }`. Defaults to `"cue-ui"`.
53
+ * localStorage key holding `{ theme, density, font, mode }`. Defaults to `"cue-ui"`.
34
54
  * Only the outermost provider reads or writes it — a nested provider is an
35
55
  * island whose explicit props are the point, not a second preference store.
36
56
  */
@@ -46,27 +66,33 @@ interface ThemeProviderProps {
46
66
  }
47
67
  /**
48
68
  * Root of the theme system: stamps `data-theme`, `data-density` and `data-mode`
49
- * so the token layer resolves, publishes `--cue-font-scale`, and owns the
50
- * persisted user preference.
69
+ * so the token layer resolves, puts `data-font` on `<html>`, publishes
70
+ * `--cue-font-scale`, and owns the persisted user preference.
51
71
  *
52
72
  * Rendering: `<div data-cue-root data-theme data-density data-mode style={{ colorScheme, --cue-font-scale }}>`
53
- * (or the single child when `asChild`). The `theme`/`density`/`mode` props are
73
+ * (or the single child when `asChild`). The `theme`/`density`/`font`/`mode` props are
54
74
  * *initial* values — the provider holds the state so `setTheme` and friends can
55
75
  * drive it — but a changed prop is adopted after mount, so a controlling parent
56
76
  * still works.
57
77
  *
78
+ * The font pairing is the one axis that never lands on this element: it has no
79
+ * island form, `<html>` is its only home, and restating it here would shadow the
80
+ * pre-paint stamp for the whole page on the first frame. See the `stamp` object
81
+ * below.
82
+ *
58
83
  * Persistence and the pre-paint contract: setters write
59
- * `{ theme, density, mode }` to `localStorage[storageKey]`, which is exactly what
84
+ * `{ theme, density, font, mode }` to `localStorage[storageKey]`, which is exactly what
60
85
  * {@link prepaintScript} reads to stamp `<html>` before the first paint. The
61
86
  * outermost provider keeps `<html>` in sync while the app runs (and restores the
62
87
  * previous stamp on unmount) so the page ground, UA scrollbars and form controls
63
- * follow the theme; nested providers never touch `<html>`.
88
+ * follow the theme; nested providers never touch `<html>`, and never own the
89
+ * font pairing — `useTheme().font` and `setFont` inside one are the root's.
64
90
  *
65
91
  * @example
66
- * <ThemeProvider theme="terminal" density="ultra-compact" mode="system">
92
+ * <ThemeProvider theme="terminal" density="ultra-compact" font="plex" mode="system">
67
93
  * <App />
68
94
  * </ThemeProvider>
69
95
  */
70
- declare function ThemeProvider({ theme: themeProp, density: densityProp, mode: modeProp, fontScale, storageKey, asChild, className, style: styleProp, children }: ThemeProviderProps): React.JSX.Element;
96
+ declare function ThemeProvider({ theme: themeProp, density: densityProp, mode: modeProp, font: fontProp, fontScale, storageKey, asChild, className, style: styleProp, children }: ThemeProviderProps): React.JSX.Element;
71
97
  //#endregion
72
98
  export { ResolvedMode, ThemeContextValue, ThemeProvider, ThemeProviderProps };