@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.
- package/dist/components/ui/ciridae-ui-provider.d.ts +2 -2
- package/dist/components/ui/ciridae-ui-provider.js +2 -2
- package/dist/components/ui/color-theme-context.d.ts +12 -0
- package/dist/components/ui/color-theme.d.ts +1 -1
- package/dist/components/ui/color-theme.js +104 -2
- package/dist/entries/palette.d.ts +2 -0
- package/dist/entries/palette.js +2 -0
- package/dist/lib/palette-presets.d.ts +17 -0
- package/dist/lib/palette-presets.js +42 -0
- package/dist/lib/palette-tokens.d.ts +39 -0
- package/dist/lib/palette-tokens.js +133 -0
- package/dist/lib/palette.d.ts +83 -0
- package/dist/lib/palette.js +151 -0
- package/package.json +4 -1
|
@@ -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
|
-
|
|
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
|
|
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,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