@panphora/clayjs 1.8.1 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,277 @@
1
+ // Page theming for the UI ClayJS draws on a page: dialogs, prompts, toasts, notices.
2
+ // A page configures it with window.clayTheme (read at first use) or clay.theme().
3
+ // resolveTheme() captures one surface's theme when that surface is built; a later
4
+ // clay.theme() call affects the next surface, never one already open.
5
+ import { pageScheme } from "./bevel-controls.js";
6
+
7
+ export const TOKEN_PROPS = {
8
+ font: "font-family",
9
+ headingFont: "font-family",
10
+ text: "color",
11
+ mutedText: "color",
12
+ background: "background-color",
13
+ surface: "background-color",
14
+ border: "border-color",
15
+ accent: "background-color",
16
+ accentText: "color",
17
+ danger: "background-color",
18
+ dangerText: "color",
19
+ success: "color",
20
+ warning: "color",
21
+ radius: "border-radius",
22
+ shadow: "box-shadow",
23
+ overlay: "background-color",
24
+ spacing: "outline-offset",
25
+ buttonHeight: "min-height",
26
+ };
27
+
28
+ export const STATES = ["base", "hover", "active", "focus", "disabled"];
29
+
30
+ const DIALOG = ["root", "overlay", "panel", "header", "title", "body", "footer", "close", "closeIcon", "content", "input", "hint", "actions", "button", "buttonLabel", "well", "code", "copyButton"];
31
+ const NOTICE = ["root", "panel", "header", "title", "text", "icon", "dot", "actions", "button", "buttonLabel", "close", "well", "list", "row", "location", "valueLabel", "value", "before", "after", "mine", "theirs", "warning", "previewRing"];
32
+
33
+ export const PART_NAMES = new Set([
34
+ "button", "button.default", "button.primary", "button.quiet", "button.danger",
35
+ "buttonLabel", "input", "text", "mutedText", "icon", "well", "close",
36
+ ...["dialog", "people", "crop"].flatMap((s) => DIALOG.map((p) => `${s}.${p}`)),
37
+ "people.choices", "people.choice", "people.choiceLabel",
38
+ "crop.stage", "crop.image", "crop.dim", "crop.selection", "crop.handle",
39
+ "crop.handle.nw", "crop.handle.ne", "crop.handle.sw", "crop.handle.se",
40
+ "toast.stack", "toast.panel", "toast.icon", "toast.text", "toast.close", "toast.closeIcon",
41
+ ...["notice", "conflict", "section", "staleHost"].flatMap((s) => NOTICE.map((p) => `${s}.${p}`)),
42
+ ]);
43
+
44
+ const SCHEMES = ["page", "light", "dark"];
45
+ const DEFAULT = Object.freeze({ auto: true, colorScheme: "page", tokens: {}, parts: {} });
46
+ const kebab = (name) => name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
47
+
48
+ let config = null;
49
+ let initialized = false;
50
+
51
+ // A CSS value a page hands us. It lands inside an inline declaration, so it may
52
+ // never close that declaration or add a priority of its own.
53
+ export function validValue(prop, value) {
54
+ if (typeof value !== "string") return false;
55
+ const v = value.trim();
56
+ if (!v || v.length > 500 || /[;{}]|!\s*important/i.test(v)) return false;
57
+ const supports = globalThis.CSS?.supports;
58
+ return typeof supports === "function" ? supports.call(globalThis.CSS, prop, v) : true;
59
+ }
60
+
61
+ function plainObject(x) {
62
+ return !!x && typeof x === "object" && Object.getPrototypeOf(x) === Object.prototype;
63
+ }
64
+
65
+ function normalize(options) {
66
+ if (!plainObject(options)) throw new TypeError("clay.theme: pass an options object, false or null");
67
+ const { auto = true, colorScheme = "page", tokens = {}, parts = {}, ...rest } = options;
68
+ const unknown = Object.keys(rest);
69
+ if (unknown.length) throw new TypeError(`clay.theme: unknown option "${unknown[0]}"`);
70
+ if (typeof auto !== "boolean") throw new TypeError("clay.theme: auto must be true or false");
71
+ if (!SCHEMES.includes(colorScheme)) throw new TypeError(`clay.theme: colorScheme must be one of ${SCHEMES.join(", ")}`);
72
+ if (!plainObject(tokens)) throw new TypeError("clay.theme: tokens must be an object");
73
+ if (!plainObject(parts)) throw new TypeError("clay.theme: parts must be an object");
74
+ const outTokens = {};
75
+ for (const [name, value] of Object.entries(tokens)) {
76
+ if (!(name in TOKEN_PROPS)) throw new TypeError(`clay.theme: unknown token "${name}"`);
77
+ if (!validValue(TOKEN_PROPS[name], value)) throw new TypeError(`clay.theme: token "${name}" is not a valid ${TOKEN_PROPS[name]} value`);
78
+ outTokens[name] = name === "spacing" && value.trim() === "0" ? "0px" : value.trim();
79
+ }
80
+ const outParts = {};
81
+ for (const [name, states] of Object.entries(parts)) {
82
+ if (!PART_NAMES.has(name)) throw new TypeError(`clay.theme: unknown part "${name}"`);
83
+ if (!plainObject(states)) throw new TypeError(`clay.theme: part "${name}" must map states to declarations`);
84
+ outParts[name] = {};
85
+ for (const [state, decls] of Object.entries(states)) {
86
+ if (!STATES.includes(state)) throw new TypeError(`clay.theme: part "${name}" has unknown state "${state}"`);
87
+ if (!plainObject(decls)) throw new TypeError(`clay.theme: "${name}.${state}" must be an object of CSS declarations`);
88
+ outParts[name][state] = {};
89
+ for (const [prop, value] of Object.entries(decls)) {
90
+ if (!/^-?[a-z][a-z-]*$/.test(prop) || prop.startsWith("--")) throw new TypeError(`clay.theme: "${prop}" in ${name}.${state} is not a CSS property name`);
91
+ if (!validValue(prop, value)) throw new TypeError(`clay.theme: "${prop}: ${value}" in ${name}.${state} is not valid`);
92
+ outParts[name][state][prop] = value.trim();
93
+ }
94
+ }
95
+ }
96
+ return Object.freeze({ auto, colorScheme, tokens: Object.freeze(outTokens), parts: Object.freeze(outParts) });
97
+ }
98
+
99
+ function copy(c) {
100
+ if (c === false) return false;
101
+ return JSON.parse(JSON.stringify(c));
102
+ }
103
+
104
+ /**
105
+ * clay.theme(options) replaces the page's theme; clay.theme(false) restores the exact
106
+ * Bevel look; clay.theme(null) restores the default automatic theme; clay.theme()
107
+ * returns a copy of the current configuration. Invalid options throw a TypeError and
108
+ * leave the previous theme in place.
109
+ */
110
+ export function theme(options) {
111
+ initTheme();
112
+ if (arguments.length === 0) return copy(config);
113
+ if (options === false) config = false;
114
+ else if (options === null) config = DEFAULT;
115
+ else config = normalize(options);
116
+ return copy(config);
117
+ }
118
+
119
+ // Idempotent. Reads window.clayTheme once and attaches clay.theme. An invalid
120
+ // preloaded theme warns and falls back to the default; it never stops ClayJS booting.
121
+ export function initTheme() {
122
+ if (initialized) return;
123
+ initialized = true;
124
+ const pre = typeof window !== "undefined" ? window.clayTheme : undefined;
125
+ if (pre === false) config = false;
126
+ else if (pre === undefined || pre === null) config = DEFAULT;
127
+ else {
128
+ try {
129
+ config = normalize(pre);
130
+ } catch (err) {
131
+ console.warn(`clayjs: ignoring window.clayTheme: ${err.message}`);
132
+ config = DEFAULT;
133
+ }
134
+ }
135
+ if (typeof window !== "undefined") {
136
+ const clay = (window.clay = window.clay || {});
137
+ clay.theme = theme;
138
+ }
139
+ }
140
+
141
+ // --- colour helpers --------------------------------------------------------
142
+
143
+ // [r, g, b, a] for rgb()/rgba() or #rgb/#rrggbb, else null.
144
+ export function parseColor(value) {
145
+ const v = String(value).trim().toLowerCase();
146
+ let m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/.exec(v);
147
+ if (m) {
148
+ const h = m[1].length === 3 ? [...m[1]].map((c) => c + c).join("") : m[1];
149
+ return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)).concat(1);
150
+ }
151
+ m = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:\s*[,/]\s*([\d.]+)(%?))?\s*\)$/.exec(v);
152
+ if (!m) return null;
153
+ const a = m[4] === undefined ? 1 : Number(m[4]) / (m[5] ? 100 : 1);
154
+ return [Number(m[1]), Number(m[2]), Number(m[3]), a];
155
+ }
156
+
157
+ function luminance([r, g, b]) {
158
+ const lin = (c) => ((c /= 255) <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
159
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
160
+ }
161
+
162
+ export function contrast(a, b) {
163
+ const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p);
164
+ return (x + 0.05) / (y + 0.05);
165
+ }
166
+
167
+ // --- sampling --------------------------------------------------------------
168
+
169
+ const TRANSPARENT = new Set(["", "transparent", "rgba(0, 0, 0, 0)"]);
170
+ const DEFAULT_FONTS = new Set(["serif", "times", "times new roman", "-webkit-standard", "tinos", "liberation serif"]);
171
+
172
+ // A family list made only of the browser's default serif means the page never chose a font.
173
+ function chosenFont(value) {
174
+ const families = String(value || "").split(",").map((f) => f.trim().replace(/^["']|["']$/g, "").toLowerCase()).filter(Boolean);
175
+ return families.length && !families.every((f) => DEFAULT_FONTS.has(f));
176
+ }
177
+
178
+ // The element's own background: null when it has none (transparent, so look further
179
+ // out), { blocked: true } when it is translucent, image-backed, or a colour this code
180
+ // cannot read, so no palette is inferred at all.
181
+ function opaqueBackground(el) {
182
+ if (!el) return null;
183
+ const cs = getComputedStyle(el);
184
+ if (cs.backgroundImage && cs.backgroundImage !== "none") return { blocked: true };
185
+ const raw = (cs.backgroundColor || "").trim();
186
+ if (TRANSPARENT.has(raw)) return null;
187
+ const c = parseColor(raw);
188
+ if (!c) return { blocked: true };
189
+ if (c[3] === 0) return null;
190
+ if (c[3] < 1) return { blocked: true };
191
+ return { value: raw, rgb: c };
192
+ }
193
+
194
+ // The page's font, and its background and text as a pair, from body and root only.
195
+ // The pair is kept only when it reads: a dark body whose text colour is set on inner
196
+ // elements would otherwise give black on near-black.
197
+ export function samplePage() {
198
+ const out = {};
199
+ const body = document.body;
200
+ if (!body) return out;
201
+ const cs = getComputedStyle(body);
202
+ if (chosenFont(cs.fontFamily)) out.font = cs.fontFamily.trim();
203
+ let bg = opaqueBackground(body);
204
+ if (!bg) bg = opaqueBackground(document.documentElement);
205
+ const text = parseColor(cs.color);
206
+ if (bg && !bg.blocked && text && text[3] === 1 && contrast(bg.rgb, text) >= 3) {
207
+ out.background = bg.value;
208
+ out.surface = bg.value;
209
+ out.text = cs.color;
210
+ }
211
+ return out;
212
+ }
213
+
214
+ function cssTokens() {
215
+ const out = {};
216
+ if (!document.body) return out;
217
+ const cs = getComputedStyle(document.body);
218
+ for (const [name, prop] of Object.entries(TOKEN_PROPS)) {
219
+ const v = cs.getPropertyValue(`--clay-ui-${kebab(name)}`).trim();
220
+ if (v && validValue(prop, v)) out[name] = name === "spacing" && v === "0" ? "0px" : v;
221
+ }
222
+ return out;
223
+ }
224
+
225
+ // "page": the root's declared scheme, else <meta name="color-scheme">, else the scheme
226
+ // of a sampled or given surface colour, else both, as unthemed ClayJS UI has always used.
227
+ function scheme(choice, tokens) {
228
+ if (choice !== "page") return choice;
229
+ const s = getComputedStyle(document.documentElement).colorScheme;
230
+ if (s && s !== "normal") return s;
231
+ const meta = document.querySelector('meta[name="color-scheme"]')?.getAttribute("content")?.trim();
232
+ if (meta && /\b(light|dark)\b/.test(meta)) return meta;
233
+ const surface = parseColor(tokens.surface || "");
234
+ if (surface) return luminance(surface) < 0.2 ? "dark" : "light";
235
+ return "light dark";
236
+ }
237
+
238
+ function onColor(fill, t) {
239
+ const f = parseColor(fill);
240
+ const a = parseColor(t.text);
241
+ const b = parseColor(t.surface);
242
+ if (f && a && b) return contrast(f, a) >= contrast(f, b) ? t.text : t.surface;
243
+ return null;
244
+ }
245
+
246
+ /**
247
+ * The theme for one surface, captured when it is built.
248
+ * Returns { bevel: true, scheme } for the exact Bevel look, otherwise
249
+ * { bevel: false, scheme, tokens, parts }. An absent token means "keep Bevel's
250
+ * declaration for that part".
251
+ */
252
+ export function resolveTheme() {
253
+ initTheme();
254
+ if (config === false) return { bevel: true, scheme: pageScheme(), tokens: {}, parts: {} };
255
+ const sampled = config.auto ? samplePage() : {};
256
+ const tokens = { ...sampled, ...cssTokens(), ...config.tokens };
257
+ if (tokens.text && tokens.surface) {
258
+ tokens.mutedText ??= `color-mix(in srgb, ${tokens.text} 70%, ${tokens.surface})`;
259
+ tokens.border ??= `color-mix(in srgb, ${tokens.text} 18%, ${tokens.surface})`;
260
+ tokens.overlay ??= `color-mix(in srgb, ${tokens.text} 20%, transparent)`;
261
+ // A page's own ink is its primary colour unless it names one.
262
+ tokens.accent ??= tokens.text;
263
+ }
264
+ for (const [fill, on] of [["accent", "accentText"], ["danger", "dangerText"]]) {
265
+ if (!tokens[fill] || tokens[on]) continue;
266
+ const pick = onColor(tokens[fill], tokens);
267
+ if (pick) tokens[on] = pick;
268
+ else console.warn(`clayjs: theme sets ${fill} without ${on}; pass ${on} so text on it stays readable`);
269
+ }
270
+ return { bevel: false, scheme: scheme(config.colorScheme, tokens), tokens, parts: config.parts };
271
+ }
272
+
273
+ // Test hook: forget configuration so the next call re-reads window.clayTheme.
274
+ export function _resetTheme() {
275
+ config = null;
276
+ initialized = false;
277
+ }