@ciridae/ui 0.6.1 → 0.7.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.
@@ -3,6 +3,6 @@ import { type ThemeProviderProps } from "./color-theme";
3
3
  export type CiridaeUiProviderProps = {
4
4
  children: ReactNode;
5
5
  agentation?: boolean;
6
- } & Pick<ThemeProviderProps, "defaultMode">;
7
- declare function CiridaeUiProvider({ agentation, children, defaultMode }: CiridaeUiProviderProps): import("@types/react").JSX.Element;
6
+ } & Pick<ThemeProviderProps, "defaultMode" | "defaultPalette">;
7
+ declare function CiridaeUiProvider({ agentation, children, defaultMode, defaultPalette, }: CiridaeUiProviderProps): import("@types/react").JSX.Element;
8
8
  export { CiridaeUiProvider };
@@ -8,7 +8,7 @@ function CiridaeUiToaster() {
8
8
  const { mode } = useTheme();
9
9
  return _jsx(Toaster, { richColors: true, closeButton: true, theme: mode });
10
10
  }
11
- function CiridaeUiProvider({ agentation = false, children, defaultMode }) {
12
- return (_jsx(ThemeProvider, { defaultMode: defaultMode, children: _jsxs(TooltipProvider, { children: [children, _jsx(CiridaeUiToaster, {}), agentation ? (_jsx(Suspense, { fallback: null, children: _jsx(LazyAgentation, {}) })) : null] }) }));
11
+ function CiridaeUiProvider({ agentation = false, children, defaultMode, defaultPalette, }) {
12
+ return (_jsx(ThemeProvider, { defaultMode: defaultMode, defaultPalette: defaultPalette, children: _jsxs(TooltipProvider, { children: [children, _jsx(CiridaeUiToaster, {}), agentation ? (_jsx(Suspense, { fallback: null, children: _jsx(LazyAgentation, {}) })) : null] }) }));
13
13
  }
14
14
  export { CiridaeUiProvider };
@@ -1,4 +1,5 @@
1
1
  import * as React from "react";
2
+ import type { PaletteSelection } from "../../lib/palette";
2
3
  type ThemeMode = "light" | "dark" | "system";
3
4
  type ResolvedTheme = "light" | "dark";
4
5
  type ThemeContextValue = {
@@ -6,11 +7,22 @@ type ThemeContextValue = {
6
7
  resolvedTheme: ResolvedTheme;
7
8
  setMode: (mode: ThemeMode) => void;
8
9
  toggleTheme: () => void;
10
+ /** `null` means the app has not opted into palettes; the stylesheet's tokens stand. */
11
+ palette: PaletteSelection | null;
12
+ setPalette: (palette: PaletteSelection) => void;
13
+ /** Drops the user's override and returns to the app's `defaultPalette`. */
14
+ resetPalette: () => void;
9
15
  };
10
16
  type ThemeProviderProps = {
11
17
  children: React.ReactNode;
12
18
  defaultMode?: ThemeMode;
19
+ /**
20
+ * The app's own identity. Omit to leave every color token to the
21
+ * stylesheet, which is what keeps an app that has not opted in unchanged.
22
+ */
23
+ defaultPalette?: PaletteSelection;
13
24
  storageKey?: string;
25
+ paletteStorageKey?: string;
14
26
  };
15
27
  declare const ThemeContext: React.Context<ThemeContextValue | undefined>;
16
28
  export { ThemeContext, type ResolvedTheme, type ThemeContextValue, type ThemeMode, type ThemeProviderProps, };
@@ -1,5 +1,5 @@
1
1
  import * as React from "react";
2
2
  import { type ResolvedTheme, type ThemeMode, type ThemeProviderProps } from "./color-theme-context";
3
- declare function ThemeProvider({ children, defaultMode, storageKey, }: ThemeProviderProps): React.JSX.Element;
3
+ declare function ThemeProvider({ children, defaultMode, defaultPalette, storageKey, paletteStorageKey, }: ThemeProviderProps): React.JSX.Element;
4
4
  export { ThemeProvider, type ResolvedTheme, type ThemeMode, type ThemeProviderProps };
5
5
  export { useTheme } from "./use-color-theme";
@@ -1,16 +1,35 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import * as React from "react";
3
+ import { PALETTE_TOKEN_NAMES, canonicalPaletteSelection, derivePaletteTokens, paletteSelectionKey, resolvePaletteSelection, } from "../../lib/palette";
3
4
  import { ThemeContext, } from "./color-theme-context";
4
5
  const defaultStorageKey = "ciridae-ui-theme";
5
- function ThemeProvider({ children, defaultMode = "system", storageKey = defaultStorageKey, }) {
6
+ const defaultPaletteStorageKey = "ciridae-ui-palette";
7
+ function ThemeProvider({ children, defaultMode = "system", defaultPalette, storageKey = defaultStorageKey, paletteStorageKey = defaultPaletteStorageKey, }) {
6
8
  const [mode, setModeState] = React.useState(() => readStoredMode(storageKey, defaultMode));
9
+ const [storedPalette, setStoredPalette] = React.useState(() => readStoredPalette(paletteStorageKey));
7
10
  const [systemTheme, setSystemTheme] = React.useState(() => getSystemTheme());
8
11
  const resolvedTheme = mode === "system" ? systemTheme : mode;
12
+ const selected = storedPalette ?? defaultPalette ?? null;
13
+ // `defaultPalette` is documented as an inline object literal, so it is a new
14
+ // reference on every render. Keyed by identity instead, the token effect and
15
+ // the context memo below stop firing on renders that changed no palette.
16
+ const paletteKey = paletteSelectionKey(selected);
17
+ // Publish the canonical selection, not the caller's object. Memoising the raw
18
+ // one would hand back a stale copy whenever two inputs share a key: setting
19
+ // hue 10.4 after 10.1 changes storage but not the key, so `palette.hue` would
20
+ // keep reporting 10.1 for the session and a control bound to it would snap
21
+ // back on any sub-degree move.
22
+ //
23
+ // Depending on `selected` is the other half: it is a new object every render,
24
+ // while `paletteKey` changes exactly when the palette does.
25
+ // eslint-disable-next-line react-hooks/exhaustive-deps
26
+ const palette = React.useMemo(() => canonicalPaletteSelection(selected), [paletteKey]);
9
27
  React.useEffect(() => {
10
28
  const root = document.documentElement;
11
29
  root.classList.remove("light", "dark");
12
30
  root.classList.add(resolvedTheme);
13
31
  }, [resolvedTheme]);
32
+ usePaletteTokens(palette, resolvedTheme);
14
33
  React.useEffect(() => {
15
34
  const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
16
35
  const updateSystemTheme = () => {
@@ -27,9 +46,63 @@ function ThemeProvider({ children, defaultMode = "system", storageKey = defaultS
27
46
  const toggleTheme = React.useCallback(() => {
28
47
  setMode(resolvedTheme === "dark" ? "light" : "dark");
29
48
  }, [resolvedTheme, setMode]);
30
- const contextValue = React.useMemo(() => ({ mode, resolvedTheme, setMode, toggleTheme }), [mode, resolvedTheme, setMode, toggleTheme]);
49
+ const setPalette = React.useCallback((nextPalette) => {
50
+ setStoredPalette(nextPalette);
51
+ localStorage.setItem(paletteStorageKey, JSON.stringify(nextPalette));
52
+ }, [paletteStorageKey]);
53
+ const resetPalette = React.useCallback(() => {
54
+ setStoredPalette(null);
55
+ localStorage.removeItem(paletteStorageKey);
56
+ }, [paletteStorageKey]);
57
+ const contextValue = React.useMemo(() => ({
58
+ mode,
59
+ resolvedTheme,
60
+ setMode,
61
+ toggleTheme,
62
+ palette,
63
+ setPalette,
64
+ resetPalette,
65
+ }), [mode, resolvedTheme, setMode, toggleTheme, palette, setPalette, resetPalette]);
31
66
  return _jsx(ThemeContext.Provider, { value: contextValue, children: children });
32
67
  }
68
+ /**
69
+ * Writes a palette onto the document element as inline custom properties.
70
+ *
71
+ * Inline values outrank both the `:root` and `.dark` rules, so they have to be
72
+ * recomputed for the resolved mode rather than only when the palette changes.
73
+ * With no palette the properties are removed outright and the stylesheet
74
+ * governs again.
75
+ */
76
+ function usePaletteTokens(palette, mode) {
77
+ // Only clear what this hook wrote. An app that sets a custom property on the
78
+ // document element itself, and never opts into palettes, would otherwise have
79
+ // it stripped on mount and on every mode change by a provider it is only
80
+ // using for light and dark.
81
+ const hasWritten = React.useRef(false);
82
+ React.useEffect(() => {
83
+ const root = document.documentElement;
84
+ const clear = () => {
85
+ if (!hasWritten.current)
86
+ return;
87
+ for (const token of PALETTE_TOKEN_NAMES)
88
+ root.style.removeProperty(`--${token}`);
89
+ hasWritten.current = false;
90
+ };
91
+ const spec = palette ? resolvePaletteSelection(palette) : null;
92
+ if (!spec) {
93
+ clear();
94
+ return;
95
+ }
96
+ for (const [token, value] of Object.entries(derivePaletteTokens(spec, mode))) {
97
+ root.style.setProperty(`--${token}`, value);
98
+ }
99
+ hasWritten.current = true;
100
+ // The properties live on an element this hook does not own, so it has to
101
+ // hand them back. Without this an unmounted provider leaves a palette on
102
+ // the document for whatever renders next.
103
+ return clear;
104
+ }, [palette, mode]);
105
+ }
33
106
  function readStoredMode(storageKey, defaultMode) {
34
107
  if (typeof window === "undefined")
35
108
  return defaultMode;
@@ -39,6 +112,35 @@ function readStoredMode(storageKey, defaultMode) {
39
112
  }
40
113
  return defaultMode;
41
114
  }
115
+ /**
116
+ * Anything unrecognised is discarded rather than trusted: this value survives
117
+ * deploys, so it outlives renamed presets and older shapes of the type.
118
+ */
119
+ function readStoredPalette(storageKey) {
120
+ if (typeof window === "undefined")
121
+ return null;
122
+ const raw = localStorage.getItem(storageKey);
123
+ if (!raw)
124
+ return null;
125
+ try {
126
+ return parseStoredPalette(JSON.parse(raw));
127
+ }
128
+ catch {
129
+ // Corrupt JSON is treated the same as no preference.
130
+ return null;
131
+ }
132
+ }
133
+ function parseStoredPalette(value) {
134
+ if (typeof value !== "object" || value === null || !("kind" in value))
135
+ return null;
136
+ if (value.kind === "preset" && "id" in value && typeof value.id === "string") {
137
+ return canonicalPaletteSelection({ kind: "preset", id: value.id });
138
+ }
139
+ if (value.kind === "custom" && "hue" in value && typeof value.hue === "number") {
140
+ return canonicalPaletteSelection({ kind: "custom", hue: value.hue });
141
+ }
142
+ return null;
143
+ }
42
144
  function getSystemTheme() {
43
145
  if (typeof window === "undefined")
44
146
  return "light";
@@ -0,0 +1,2 @@
1
+ export * from "../lib/palette";
2
+ export * from "../lib/palette-presets";
@@ -0,0 +1,2 @@
1
+ export * from "../lib/palette";
2
+ export * from "../lib/palette-presets";
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The palettes an app can name.
3
+ *
4
+ * Five, chosen to be visibly distinct from one another: below about 0.02 in
5
+ * OKLab two swatches read as the same color, and every pair here clears that by
6
+ * at least fivefold. That rules out a second neutral -- a warm ground differs
7
+ * from a cool one by roughly 0.006, real across a whole page but invisible in a
8
+ * swatch -- so Slate is the only one without a brand hue of its own.
9
+ *
10
+ * Anything else is a hue away: `customPaletteSpec` in ./palette builds a palette
11
+ * from a single number, and `scripts/check-palette-contrast.mjs` proves every
12
+ * one of those is readable too.
13
+ */
14
+ import type { PalettePreset } from "./palette";
15
+ declare const PALETTE_PRESETS: readonly PalettePreset[];
16
+ declare const PALETTE_PRESETS_BY_ID: ReadonlyMap<string, PalettePreset>;
17
+ export { PALETTE_PRESETS, PALETTE_PRESETS_BY_ID };
@@ -0,0 +1,42 @@
1
+ const PALETTE_PRESETS = Object.freeze([
2
+ // The theme the template shipped before palettes existed, and the way back to it.
3
+ { id: "slate", label: "Slate", spec: { groundHue: 260, groundChroma: 0.002 } },
4
+ {
5
+ id: "clay",
6
+ label: "Clay",
7
+ spec: {
8
+ groundHue: 55,
9
+ groundChroma: 0.008,
10
+ brand: { hue: 50, chroma: 0.11, lightness: 0.44 },
11
+ },
12
+ },
13
+ {
14
+ id: "iris",
15
+ label: "Iris",
16
+ spec: {
17
+ groundHue: 275,
18
+ groundChroma: 0.005,
19
+ brand: { hue: 285, chroma: 0.13, lightness: 0.42 },
20
+ },
21
+ },
22
+ {
23
+ id: "teal",
24
+ label: "Teal",
25
+ spec: {
26
+ groundHue: 200,
27
+ groundChroma: 0.005,
28
+ brand: { hue: 195, chroma: 0.1, lightness: 0.44 },
29
+ },
30
+ },
31
+ {
32
+ id: "plum",
33
+ label: "Plum",
34
+ spec: {
35
+ groundHue: 320,
36
+ groundChroma: 0.005,
37
+ brand: { hue: 330, chroma: 0.12, lightness: 0.42 },
38
+ },
39
+ },
40
+ ]);
41
+ const PALETTE_PRESETS_BY_ID = new Map(PALETTE_PRESETS.map((preset) => [preset.id, preset]));
42
+ export { PALETTE_PRESETS, PALETTE_PRESETS_BY_ID };
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The fixed parts of the theme: the lightness ladder every palette is built on,
3
+ * and the status colors no palette is allowed to move.
4
+ *
5
+ * Kept apart from ./palette so the derivation reads as logic rather than data.
6
+ */
7
+ type PaletteMode = "light" | "dark";
8
+ type LadderEntry = {
9
+ l: number;
10
+ /** Multiplier on the palette's ground chroma. */
11
+ c: number;
12
+ /** Alpha, for the tokens that carry one. */
13
+ a?: number;
14
+ /** Takes the brand hue and chroma, but only when the palette declares a brand. */
15
+ brand?: true;
16
+ };
17
+ /**
18
+ * Every color token, as lightness plus a multiplier on the palette's ground
19
+ * chroma. Read straight out of ciridae.css against its 0.002 chroma, so the
20
+ * Slate preset reproduces the stylesheet exactly -- `check-palette-contrast.mjs`
21
+ * asserts that, and will fail if the two ever drift.
22
+ *
23
+ * A palette moves hue and chroma but never the lightness, which is what keeps
24
+ * the contrast gates cleared: WCAG contrast is dominated by lightness.
25
+ */
26
+ declare const LADDER: Record<PaletteMode, Record<string, LadderEntry>>;
27
+ /**
28
+ * Status stays semantic across every palette: an error is the same red whatever
29
+ * brand the app wears, so "failed" never has to be relearned per theme. The last
30
+ * two chart series sit here for the same reason -- they keep a five-series chart
31
+ * readable when the brand-led three are close together.
32
+ */
33
+ declare const STATUS_TOKENS: Record<PaletteMode, Record<string, string>>;
34
+ /** Tokens whose fill carries the brand rather than a surface step. */
35
+ declare const BRAND_FILLS: Set<string>;
36
+ /** Hover surfaces take a fraction of the brand chroma so they read as tinted, not saturated. */
37
+ declare const ACCENT_SURFACES: Set<string>;
38
+ declare const ACCENT_FOREGROUNDS: Set<string>;
39
+ export { ACCENT_FOREGROUNDS, ACCENT_SURFACES, BRAND_FILLS, LADDER, STATUS_TOKENS, type LadderEntry, type PaletteMode, };
@@ -0,0 +1,133 @@
1
+ /**
2
+ * The fixed parts of the theme: the lightness ladder every palette is built on,
3
+ * and the status colors no palette is allowed to move.
4
+ *
5
+ * Kept apart from ./palette so the derivation reads as logic rather than data.
6
+ */
7
+ /**
8
+ * Every color token, as lightness plus a multiplier on the palette's ground
9
+ * chroma. Read straight out of ciridae.css against its 0.002 chroma, so the
10
+ * Slate preset reproduces the stylesheet exactly -- `check-palette-contrast.mjs`
11
+ * asserts that, and will fail if the two ever drift.
12
+ *
13
+ * A palette moves hue and chroma but never the lightness, which is what keeps
14
+ * the contrast gates cleared: WCAG contrast is dominated by lightness.
15
+ */
16
+ const LADDER = {
17
+ light: {
18
+ background: { l: 0.985, c: 0.5 },
19
+ foreground: { l: 0.145, c: 1 },
20
+ card: { l: 1, c: 0 },
21
+ "card-foreground": { l: 0.145, c: 1 },
22
+ popover: { l: 1, c: 0 },
23
+ "popover-foreground": { l: 0.145, c: 1 },
24
+ primary: { l: 0.145, c: 1, brand: true },
25
+ "primary-foreground": { l: 0.99, c: 0 },
26
+ secondary: { l: 0.955, c: 1 },
27
+ "secondary-foreground": { l: 0.2, c: 1 },
28
+ muted: { l: 0.955, c: 1 },
29
+ "muted-foreground": { l: 0.46, c: 1.5 },
30
+ accent: { l: 0.935, c: 1, brand: true },
31
+ "accent-foreground": { l: 0.18, c: 1, brand: true },
32
+ border: { l: 0.865, c: 1 },
33
+ input: { l: 0.835, c: 1 },
34
+ ring: { l: 0.145, c: 1, brand: true },
35
+ overlay: { l: 0.145, c: 1, a: 0.58 },
36
+ surface: { l: 1, c: 0 },
37
+ "surface-foreground": { l: 0.145, c: 1 },
38
+ "surface-inverse": { l: 0.145, c: 1 },
39
+ "surface-inverse-foreground": { l: 0.99, c: 0 },
40
+ "chart-1": { l: 0.22, c: 1, brand: true },
41
+ "chart-2": { l: 0.5, c: 1, brand: true },
42
+ "chart-3": { l: 0.72, c: 1, brand: true },
43
+ sidebar: { l: 0.965, c: 0.5 },
44
+ "sidebar-foreground": { l: 0.23, c: 1 },
45
+ "sidebar-primary": { l: 0.145, c: 1, brand: true },
46
+ "sidebar-primary-foreground": { l: 0.99, c: 0 },
47
+ "sidebar-accent": { l: 0.935, c: 1, brand: true },
48
+ "sidebar-accent-foreground": { l: 0.18, c: 1, brand: true },
49
+ "sidebar-border": { l: 0.865, c: 1 },
50
+ "sidebar-ring": { l: 0.145, c: 1, brand: true },
51
+ "table-header-foreground": { l: 0.42, c: 1.5 },
52
+ },
53
+ dark: {
54
+ background: { l: 0.145, c: 1 },
55
+ foreground: { l: 0.935, c: 0.5 },
56
+ card: { l: 0.185, c: 1 },
57
+ "card-foreground": { l: 0.935, c: 0.5 },
58
+ popover: { l: 0.205, c: 1 },
59
+ "popover-foreground": { l: 0.935, c: 0.5 },
60
+ primary: { l: 0.965, c: 0.5, brand: true },
61
+ "primary-foreground": { l: 0.145, c: 1 },
62
+ secondary: { l: 0.255, c: 1 },
63
+ "secondary-foreground": { l: 0.94, c: 0.5 },
64
+ muted: { l: 0.255, c: 1 },
65
+ "muted-foreground": { l: 0.7, c: 1 },
66
+ accent: { l: 0.275, c: 1, brand: true },
67
+ "accent-foreground": { l: 0.94, c: 0.5, brand: true },
68
+ border: { l: 0.32, c: 1 },
69
+ input: { l: 0.36, c: 1 },
70
+ ring: { l: 0.88, c: 0.5, brand: true },
71
+ overlay: { l: 0.08, c: 0.5, a: 0.72 },
72
+ surface: { l: 0.205, c: 1 },
73
+ "surface-foreground": { l: 0.935, c: 0.5 },
74
+ "surface-inverse": { l: 0.935, c: 0.5 },
75
+ "surface-inverse-foreground": { l: 0.145, c: 1 },
76
+ "chart-1": { l: 0.9, c: 0.5, brand: true },
77
+ "chart-2": { l: 0.68, c: 1, brand: true },
78
+ "chart-3": { l: 0.48, c: 1, brand: true },
79
+ sidebar: { l: 0.18, c: 1 },
80
+ "sidebar-foreground": { l: 0.88, c: 0.5 },
81
+ "sidebar-primary": { l: 0.965, c: 0.5, brand: true },
82
+ "sidebar-primary-foreground": { l: 0.145, c: 1 },
83
+ "sidebar-accent": { l: 0.235, c: 1, brand: true },
84
+ "sidebar-accent-foreground": { l: 0.94, c: 0.5, brand: true },
85
+ "sidebar-border": { l: 0.32, c: 1 },
86
+ "sidebar-ring": { l: 0.88, c: 0.5, brand: true },
87
+ "table-header-foreground": { l: 0.78, c: 1 },
88
+ },
89
+ };
90
+ /**
91
+ * Status stays semantic across every palette: an error is the same red whatever
92
+ * brand the app wears, so "failed" never has to be relearned per theme. The last
93
+ * two chart series sit here for the same reason -- they keep a five-series chart
94
+ * readable when the brand-led three are close together.
95
+ */
96
+ const STATUS_TOKENS = {
97
+ light: {
98
+ destructive: "oklch(0.48 0.13 27)",
99
+ "destructive-foreground": "oklch(0.99 0 0)",
100
+ success: "oklch(0.44 0.075 150)",
101
+ "success-foreground": "oklch(0.99 0 0)",
102
+ "success-muted": "oklch(0.94 0.03 150)",
103
+ info: "oklch(0.43 0.045 245)",
104
+ "info-foreground": "oklch(0.99 0 0)",
105
+ "info-muted": "oklch(0.94 0.018 245)",
106
+ warning: "oklch(0.52 0.075 85)",
107
+ "warning-foreground": "oklch(0.99 0 0)",
108
+ "warning-muted": "oklch(0.95 0.035 85)",
109
+ "chart-4": "oklch(0.5 0.055 245)",
110
+ "chart-5": "oklch(0.5 0.07 150)",
111
+ },
112
+ dark: {
113
+ destructive: "oklch(0.65 0.12 27)",
114
+ "destructive-foreground": "oklch(0.12 0.002 260)",
115
+ success: "oklch(0.68 0.075 150)",
116
+ "success-foreground": "oklch(0.12 0.002 260)",
117
+ "success-muted": "oklch(0.25 0.035 150)",
118
+ info: "oklch(0.7 0.05 245)",
119
+ "info-foreground": "oklch(0.12 0.002 260)",
120
+ "info-muted": "oklch(0.25 0.028 245)",
121
+ warning: "oklch(0.76 0.075 85)",
122
+ "warning-foreground": "oklch(0.12 0.002 260)",
123
+ "warning-muted": "oklch(0.27 0.04 85)",
124
+ "chart-4": "oklch(0.72 0.065 245)",
125
+ "chart-5": "oklch(0.7 0.1 150)",
126
+ },
127
+ };
128
+ /** Tokens whose fill carries the brand rather than a surface step. */
129
+ const BRAND_FILLS = new Set(["primary", "sidebar-primary", "ring", "sidebar-ring"]);
130
+ /** Hover surfaces take a fraction of the brand chroma so they read as tinted, not saturated. */
131
+ const ACCENT_SURFACES = new Set(["accent", "sidebar-accent"]);
132
+ const ACCENT_FOREGROUNDS = new Set(["accent-foreground", "sidebar-accent-foreground"]);
133
+ export { ACCENT_FOREGROUNDS, ACCENT_SURFACES, BRAND_FILLS, LADDER, STATUS_TOKENS, };
@@ -0,0 +1,83 @@
1
+ import { type PaletteMode } from "./palette-tokens";
2
+ /**
3
+ * A palette's brand, supplied whole.
4
+ *
5
+ * One object rather than three optional fields so the two states -- ground-only
6
+ * and branded -- are the only ones a caller can express. Separately optional
7
+ * hue, chroma, and lightness let a type-valid spec colour the primary while the
8
+ * charts took the ground-only path.
9
+ */
10
+ type PaletteBrand = {
11
+ hue: number;
12
+ chroma: number;
13
+ /** Light-mode lightness of primary. Dark mode lifts it to stay legible. */
14
+ lightness: number;
15
+ };
16
+ type PaletteSpec = {
17
+ /** Hue for surfaces, borders, and text. */
18
+ groundHue: number;
19
+ /** Chroma for those neutrals. Tiny values (< 0.015) read as a tint, not a color. */
20
+ groundChroma: number;
21
+ /** Omit for a palette that is only a tinted ground, keeping a near-black primary. */
22
+ brand?: PaletteBrand;
23
+ };
24
+ type PalettePreset = {
25
+ id: string;
26
+ label: string;
27
+ spec: PaletteSpec;
28
+ };
29
+ /**
30
+ * Full token map for one palette in one mode, as `--name` -> oklch string.
31
+ * Applied as inline custom properties on the document element, which is why the
32
+ * caller must recompute when the light/dark mode changes: an inline value wins
33
+ * over both the `:root` and `.dark` rules.
34
+ *
35
+ * The readability guarantee covers the specs `check-palette-contrast.mjs`
36
+ * sweeps: the named presets and anything `customPaletteSpec` returns. A spec
37
+ * assembled by hand with its own chroma and lightness is derived faithfully but
38
+ * has never been graded.
39
+ */
40
+ declare function derivePaletteTokens(spec: PaletteSpec, mode: PaletteMode): Record<string, string>;
41
+ /** Every token a palette writes. Used to clear stale properties when switching. */
42
+ declare const PALETTE_TOKEN_NAMES: readonly string[];
43
+ /**
44
+ * A palette from a hue alone, for an app whose brand is not in the preset list.
45
+ * Ground hue follows the brand, so there is no combination that clashes with
46
+ * itself and only the hue has to be chosen.
47
+ *
48
+ * Throws on a hue that is not finite: `oklch(0.43 0.12 NaN)` is not an error any
49
+ * browser reports, it silently voids every declaration that uses the token.
50
+ *
51
+ * The hue is normalised to a whole degree, which is what makes the contrast
52
+ * gate's sweep total rather than a sample: a fraction of a degree is invisible,
53
+ * but an unrounded hue would be a reachable palette the sweep never graded.
54
+ */
55
+ declare function customPaletteSpec(hue: number): PaletteSpec;
56
+ /** What an app or a user has chosen. `null` elsewhere means "use the stylesheet". */
57
+ type PaletteSelection = {
58
+ kind: "preset";
59
+ id: string;
60
+ } | {
61
+ kind: "custom";
62
+ hue: number;
63
+ };
64
+ /**
65
+ * The selection as the derivation reads it: a hue normalised to the whole degree
66
+ * the tokens come from, and `null` for anything that resolves to nothing -- a
67
+ * preset id that no longer exists, a hue that is not finite.
68
+ *
69
+ * This is the single validating boundary for a selection from anywhere
70
+ * untrusted: a stored value, a URL, an app's own config. Publishing it rather
71
+ * than the caller's own object also keeps `useTheme().palette` equal to what is
72
+ * on the document, so a control bound to `palette.hue` reads back the palette it
73
+ * is looking at.
74
+ */
75
+ declare function canonicalPaletteSelection(selection: PaletteSelection | null): PaletteSelection | null;
76
+ /** The spec a selection derives, or `null` if it does not resolve to one. */
77
+ declare function resolvePaletteSelection(selection: PaletteSelection): PaletteSpec | null;
78
+ /**
79
+ * Stable identity for a selection, so a caller holding an inline
80
+ * `defaultPalette` object literal does not look like a new palette every render.
81
+ */
82
+ declare function paletteSelectionKey(selection: PaletteSelection | null): string;
83
+ export { PALETTE_TOKEN_NAMES, canonicalPaletteSelection, customPaletteSpec, derivePaletteTokens, paletteSelectionKey, resolvePaletteSelection, type PaletteBrand, type PaletteMode, type PalettePreset, type PaletteSelection, type PaletteSpec, };
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Palette derivation for the Ciridae theme.
3
+ *
4
+ * A palette is a ground plus an optional brand, not a hand-authored token set:
5
+ * ./palette-tokens owns the lightness ladder, and a palette moves only hue and
6
+ * chroma over it. Holding lightness fixed is what keeps every palette clearing
7
+ * the contrast gates, because WCAG contrast is dominated by lightness;
8
+ * `scripts/check-palette-contrast.mjs` proves that across both the presets and
9
+ * every whole-degree hue `customPaletteSpec` can produce.
10
+ *
11
+ * Named presets and custom hues run through `derivePaletteTokens` alike, so
12
+ * there is no second code path that could drift from the validated one.
13
+ */
14
+ import { PALETTE_PRESETS_BY_ID } from "./palette-presets";
15
+ import { ACCENT_FOREGROUNDS, ACCENT_SURFACES, BRAND_FILLS, LADDER, STATUS_TOKENS, } from "./palette-tokens";
16
+ function round(value, places) {
17
+ return Number.parseFloat(value.toFixed(places));
18
+ }
19
+ /** Hue is dropped at zero chroma, where it says nothing and `oklch(1 0 0)` is the idiom. */
20
+ function formatOklch(lightness, chroma, hue, alpha) {
21
+ const c = round(chroma, 4);
22
+ const channels = `${round(lightness, 3)} ${c} ${c === 0 ? 0 : hue}`;
23
+ return alpha === undefined ? `oklch(${channels})` : `oklch(${channels} / ${alpha})`;
24
+ }
25
+ function brandChroma(token, brand, mode) {
26
+ if (ACCENT_SURFACES.has(token))
27
+ return brand.chroma * (mode === "light" ? 0.22 : 0.3);
28
+ if (ACCENT_FOREGROUNDS.has(token))
29
+ return brand.chroma * 0.35;
30
+ return brand.chroma;
31
+ }
32
+ function brandLightness(token, entry, brand, mode) {
33
+ if (!BRAND_FILLS.has(token))
34
+ return entry.l;
35
+ if (mode === "light")
36
+ return brand.lightness;
37
+ // A saturated brand at its light-mode lightness disappears into a dark
38
+ // ground, so the fill lifts rather than inverting to near-white.
39
+ return Math.min(0.82, brand.lightness + 0.32);
40
+ }
41
+ /**
42
+ * Full token map for one palette in one mode, as `--name` -> oklch string.
43
+ * Applied as inline custom properties on the document element, which is why the
44
+ * caller must recompute when the light/dark mode changes: an inline value wins
45
+ * over both the `:root` and `.dark` rules.
46
+ *
47
+ * The readability guarantee covers the specs `check-palette-contrast.mjs`
48
+ * sweeps: the named presets and anything `customPaletteSpec` returns. A spec
49
+ * assembled by hand with its own chroma and lightness is derived faithfully but
50
+ * has never been graded.
51
+ */
52
+ function derivePaletteTokens(spec, mode) {
53
+ const tokens = {};
54
+ for (const [token, entry] of Object.entries(LADDER[mode])) {
55
+ // A palette with no brand is entirely its ground, so the brand-marked
56
+ // tokens fall back to the ladder untouched. That is what lets Slate come
57
+ // out byte-identical to ciridae.css rather than as a near-copy.
58
+ const brand = entry.brand === true ? spec.brand : undefined;
59
+ const hue = brand ? brand.hue : spec.groundHue;
60
+ const chroma = brand ? brandChroma(token, brand, mode) : spec.groundChroma * entry.c;
61
+ const lightness = brand ? brandLightness(token, entry, brand, mode) : entry.l;
62
+ tokens[token] = formatOklch(lightness, chroma, hue, entry.a);
63
+ }
64
+ return { ...tokens, ...STATUS_TOKENS[mode] };
65
+ }
66
+ /** Every token a palette writes. Used to clear stale properties when switching. */
67
+ const PALETTE_TOKEN_NAMES = Object.freeze([
68
+ ...new Set([
69
+ ...Object.keys(LADDER.light),
70
+ ...Object.keys(LADDER.dark),
71
+ ...Object.keys(STATUS_TOKENS.light),
72
+ ...Object.keys(STATUS_TOKENS.dark),
73
+ ]),
74
+ ]);
75
+ /** Chroma for a hue-only palette: the middle of the range the presets use. */
76
+ const CUSTOM_BRAND_CHROMA = 0.12;
77
+ /**
78
+ * Whole degrees in [0, 360) -- the space `check-palette-contrast.mjs` sweeps.
79
+ *
80
+ * Shared so a hue and its identity cannot normalise differently: 360, 0, and
81
+ * 359.6 all derive the same palette, so they must all name it the same way.
82
+ */
83
+ function normalizeHue(hue) {
84
+ return Math.round(((hue % 360) + 360) % 360) % 360;
85
+ }
86
+ /**
87
+ * A palette from a hue alone, for an app whose brand is not in the preset list.
88
+ * Ground hue follows the brand, so there is no combination that clashes with
89
+ * itself and only the hue has to be chosen.
90
+ *
91
+ * Throws on a hue that is not finite: `oklch(0.43 0.12 NaN)` is not an error any
92
+ * browser reports, it silently voids every declaration that uses the token.
93
+ *
94
+ * The hue is normalised to a whole degree, which is what makes the contrast
95
+ * gate's sweep total rather than a sample: a fraction of a degree is invisible,
96
+ * but an unrounded hue would be a reachable palette the sweep never graded.
97
+ */
98
+ function customPaletteSpec(hue) {
99
+ if (!Number.isFinite(hue))
100
+ throw new Error(`Custom palette hue must be finite, got ${hue}`);
101
+ const normalizedHue = normalizeHue(hue);
102
+ return {
103
+ groundHue: normalizedHue,
104
+ groundChroma: 0.005,
105
+ brand: { hue: normalizedHue, chroma: CUSTOM_BRAND_CHROMA, lightness: 0.43 },
106
+ };
107
+ }
108
+ /**
109
+ * The selection as the derivation reads it: a hue normalised to the whole degree
110
+ * the tokens come from, and `null` for anything that resolves to nothing -- a
111
+ * preset id that no longer exists, a hue that is not finite.
112
+ *
113
+ * This is the single validating boundary for a selection from anywhere
114
+ * untrusted: a stored value, a URL, an app's own config. Publishing it rather
115
+ * than the caller's own object also keeps `useTheme().palette` equal to what is
116
+ * on the document, so a control bound to `palette.hue` reads back the palette it
117
+ * is looking at.
118
+ */
119
+ function canonicalPaletteSelection(selection) {
120
+ if (!selection)
121
+ return null;
122
+ if (selection.kind === "preset") {
123
+ return PALETTE_PRESETS_BY_ID.has(selection.id) ? selection : null;
124
+ }
125
+ return Number.isFinite(selection.hue)
126
+ ? { kind: "custom", hue: normalizeHue(selection.hue) }
127
+ : null;
128
+ }
129
+ /** The spec a selection derives, or `null` if it does not resolve to one. */
130
+ function resolvePaletteSelection(selection) {
131
+ const canonical = canonicalPaletteSelection(selection);
132
+ if (!canonical)
133
+ return null;
134
+ return canonical.kind === "preset"
135
+ ? (PALETTE_PRESETS_BY_ID.get(canonical.id)?.spec ?? null)
136
+ : customPaletteSpec(canonical.hue);
137
+ }
138
+ /**
139
+ * Stable identity for a selection, so a caller holding an inline
140
+ * `defaultPalette` object literal does not look like a new palette every render.
141
+ */
142
+ function paletteSelectionKey(selection) {
143
+ if (!selection)
144
+ return "none";
145
+ if (selection.kind === "preset")
146
+ return `preset:${selection.id}`;
147
+ // Normalised exactly as `customPaletteSpec` does, so two hues that derive the
148
+ // same palette cannot read as two different selections.
149
+ return `custom:${normalizeHue(selection.hue)}`;
150
+ }
151
+ export { PALETTE_TOKEN_NAMES, canonicalPaletteSelection, customPaletteSpec, derivePaletteTokens, paletteSelectionKey, resolvePaletteSelection, };
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@ciridae/ui",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "private": false,
6
+ "engines": {
7
+ "node": ">=22.18"
8
+ },
6
9
  "repository": {
7
10
  "type": "git",
8
11
  "url": "git+https://github.com/ciridae-ai/ciridae.git",