@loidolt/theme-svelte 0.6.0 → 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/index.d.ts CHANGED
@@ -105,6 +105,8 @@ export { strokeLength, strokePath, strokesToSvg, validateSignature } from './sig
105
105
  export type { SignatureError, SignaturePoint, SignatureStroke, SignatureValue, } from './signature.js';
106
106
  export { createDataTable } from './data-table.svelte.js';
107
107
  export type { DataTableColumn, DataTableFilterValue, DataTableOptions, DataTableRow, DataTableSort, DataTableState, } from './data-table.svelte.js';
108
+ export { colorSchemeOf, createTokenColors, normalizeColor, observeColorScheme, readRoleColor, } from './token-colors.svelte.js';
109
+ export type { TokenColors, TokenColorsOptions } from './token-colors.svelte.js';
108
110
  export { createAnnouncer } from './announcer.svelte.js';
109
111
  export type { Announcer, AnnouncerOptions, Politeness } from './announcer.svelte.js';
110
112
  export { createToaster } from './toaster.svelte.js';
package/dist/index.js CHANGED
@@ -96,6 +96,7 @@ export { canPlayHlsNatively, loadHls, setHlsLoader } from './hls.js';
96
96
  export { createHlsSource } from './hls.svelte.js';
97
97
  export { strokeLength, strokePath, strokesToSvg, validateSignature } from './signature.js';
98
98
  export { createDataTable } from './data-table.svelte.js';
99
+ export { colorSchemeOf, createTokenColors, normalizeColor, observeColorScheme, readRoleColor, } from './token-colors.svelte.js';
99
100
  export { createAnnouncer } from './announcer.svelte.js';
100
101
  export { createToaster } from './toaster.svelte.js';
101
102
  export { createTheme, themeScript } from './theme.svelte.js';
@@ -0,0 +1,49 @@
1
+ import { type ColorScheme, type ResolvedRoles, type RoleSpec, type SemanticRole } from '@loidolt/theme-tokens';
2
+ /**
3
+ * A computed colour as `#rrggbb`, or `#rrggbbaa` when it is translucent. Accepts what browsers
4
+ * return from `getComputedStyle`: `rgb()`/`rgba()` in either syntax, and `color(srgb …)`, which
5
+ * is what a `color-mix()` token computes to. Anything else is `null`.
6
+ */
7
+ export declare function normalizeColor(value: string): string | null;
8
+ /**
9
+ * Reads one role's live value from the page, as the element would paint it — so a scoped
10
+ * `[data-theme]` subtree or an app's own overrides are honoured. `null` when the environment
11
+ * cannot say (the server, or a DOM without custom-property support such as jsdom).
12
+ */
13
+ export declare function readRoleColor(element: Element, role: SemanticRole): string | null;
14
+ /**
15
+ * Which theme an element is in: an explicit `data-theme` on it or an ancestor wins, then the
16
+ * computed `color-scheme` (which the dark stylesheets set, including the system-following one).
17
+ */
18
+ export declare function colorSchemeOf(element: Element | null | undefined): ColorScheme;
19
+ /**
20
+ * Calls `callback` whenever the page's colour scheme may have changed: a `data-theme` anywhere,
21
+ * a class or inline style on `<html>`, or the system preference. Returns the unsubscribe.
22
+ */
23
+ export declare function observeColorScheme(callback: () => void): () => void;
24
+ export interface TokenColorsOptions {
25
+ /** Where to read from. Defaults to `<html>`; pass the chart or map element to honour a scoped theme. */
26
+ element?: () => Element | null | undefined;
27
+ }
28
+ export interface TokenColors<S extends RoleSpec> {
29
+ /** Every role in the spec as a concrete colour. Reactive. */
30
+ readonly colors: ResolvedRoles<S>;
31
+ readonly scheme: ColorScheme;
32
+ /** Goes up by one each time `colors` changes, for effects that repaint a canvas. */
33
+ readonly version: number;
34
+ /** Re-reads the page now — call it once the element has mounted. */
35
+ refresh(): void;
36
+ destroy(): void;
37
+ }
38
+ /**
39
+ * Semantic token values for renderers that cannot read CSS — a chart canvas, a WebGL map. Reads
40
+ * the live values off the page, so an app's own theme reaches the canvas, and follows theme
41
+ * changes. On the server, and wherever values cannot be read, it falls back to the reference
42
+ * light or dark theme.
43
+ *
44
+ * ```ts
45
+ * const palette = createTokenColors({ series: ['chart1', 'chart2'], ink: 'text' }, { element: () => node });
46
+ * $effect(() => chart?.setOption(build(palette.colors)));
47
+ * ```
48
+ */
49
+ export declare function createTokenColors<S extends RoleSpec>(spec: S, options?: TokenColorsOptions): TokenColors<S>;
@@ -0,0 +1,165 @@
1
+ import { onDestroy } from 'svelte';
2
+ import { resolveRoles, roleVar, } from '@loidolt/theme-tokens';
3
+ const hex = (value) => Math.round(Math.min(255, Math.max(0, value)))
4
+ .toString(16)
5
+ .padStart(2, '0');
6
+ /**
7
+ * A computed colour as `#rrggbb`, or `#rrggbbaa` when it is translucent. Accepts what browsers
8
+ * return from `getComputedStyle`: `rgb()`/`rgba()` in either syntax, and `color(srgb …)`, which
9
+ * is what a `color-mix()` token computes to. Anything else is `null`.
10
+ */
11
+ export function normalizeColor(value) {
12
+ const text = value.trim().toLowerCase();
13
+ if (/^#[0-9a-f]{6}([0-9a-f]{2})?$/.test(text))
14
+ return text;
15
+ const rgb = /^rgba?\(([^)]+)\)$/.exec(text);
16
+ const srgb = /^color\(srgb ([^)]+)\)$/.exec(text);
17
+ const parts = (rgb?.[1] ?? srgb?.[1])?.split(/[\s,/]+/).filter(Boolean);
18
+ if (!parts || parts.length < 3)
19
+ return null;
20
+ const numbers = parts.map((part) => part.endsWith('%') ? Number.parseFloat(part) / 100 : Number(part));
21
+ if (numbers.some((number) => !Number.isFinite(number)))
22
+ return null;
23
+ const channels = numbers.slice(0, 3).map((number, index) =>
24
+ // `rgb()` channels are 0–255 unless written as percentages; `color(srgb)` channels are 0–1.
25
+ srgb || parts[index].endsWith('%') ? number * 255 : number);
26
+ const alpha = numbers.length > 3 ? numbers[3] : 1;
27
+ const base = `#${channels.map(hex).join('')}`;
28
+ return alpha >= 1 ? base : `${base}${hex(alpha * 255)}`;
29
+ }
30
+ /**
31
+ * Reads one role's live value from the page, as the element would paint it — so a scoped
32
+ * `[data-theme]` subtree or an app's own overrides are honoured. `null` when the environment
33
+ * cannot say (the server, or a DOM without custom-property support such as jsdom).
34
+ */
35
+ export function readRoleColor(element, role) {
36
+ const view = element.ownerDocument?.defaultView;
37
+ if (!view)
38
+ return null;
39
+ const probe = element.ownerDocument.createElement('span');
40
+ probe.hidden = true;
41
+ probe.style.color = `var(${roleVar(role)})`;
42
+ element.appendChild(probe);
43
+ try {
44
+ return normalizeColor(view.getComputedStyle(probe).color);
45
+ }
46
+ finally {
47
+ probe.remove();
48
+ }
49
+ }
50
+ /**
51
+ * Which theme an element is in: an explicit `data-theme` on it or an ancestor wins, then the
52
+ * computed `color-scheme` (which the dark stylesheets set, including the system-following one).
53
+ */
54
+ export function colorSchemeOf(element) {
55
+ if (!element)
56
+ return 'light';
57
+ const explicit = element.closest('[data-theme]')?.getAttribute('data-theme');
58
+ if (explicit === 'dark' || explicit === 'light')
59
+ return explicit;
60
+ const view = element.ownerDocument?.defaultView;
61
+ const computed = view?.getComputedStyle(element).colorScheme ?? '';
62
+ return /\bdark\b/.test(computed) && !/\blight\b/.test(computed) ? 'dark' : 'light';
63
+ }
64
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- subscriber bookkeeping, never rendered
65
+ const listeners = new Set();
66
+ let teardown;
67
+ /** One set of observers for the whole page, however many charts and maps are listening. */
68
+ function subscribe(listener) {
69
+ listeners.add(listener);
70
+ if (!teardown && typeof document !== 'undefined') {
71
+ let queued = false;
72
+ const notify = () => {
73
+ if (queued)
74
+ return;
75
+ queued = true;
76
+ queueMicrotask(() => {
77
+ queued = false;
78
+ for (const each of [...listeners])
79
+ each();
80
+ });
81
+ };
82
+ const root = document.documentElement;
83
+ const scoped = new MutationObserver(notify);
84
+ // A theme can be scoped to any subtree, so `data-theme` is watched everywhere; `class` and
85
+ // `style` only on the root, where apps toggle theme classes and inline overrides.
86
+ scoped.observe(root, { attributes: true, attributeFilter: ['data-theme'], subtree: true });
87
+ const rootObserver = new MutationObserver(notify);
88
+ rootObserver.observe(root, { attributes: true, attributeFilter: ['class', 'style'] });
89
+ const media = typeof window.matchMedia === 'function'
90
+ ? window.matchMedia('(prefers-color-scheme: dark)')
91
+ : undefined;
92
+ media?.addEventListener('change', notify);
93
+ teardown = () => {
94
+ scoped.disconnect();
95
+ rootObserver.disconnect();
96
+ media?.removeEventListener('change', notify);
97
+ teardown = undefined;
98
+ };
99
+ }
100
+ return () => {
101
+ listeners.delete(listener);
102
+ if (listeners.size === 0)
103
+ teardown?.();
104
+ };
105
+ }
106
+ /**
107
+ * Calls `callback` whenever the page's colour scheme may have changed: a `data-theme` anywhere,
108
+ * a class or inline style on `<html>`, or the system preference. Returns the unsubscribe.
109
+ */
110
+ export function observeColorScheme(callback) {
111
+ return subscribe(callback);
112
+ }
113
+ /**
114
+ * Semantic token values for renderers that cannot read CSS — a chart canvas, a WebGL map. Reads
115
+ * the live values off the page, so an app's own theme reaches the canvas, and follows theme
116
+ * changes. On the server, and wherever values cannot be read, it falls back to the reference
117
+ * light or dark theme.
118
+ *
119
+ * ```ts
120
+ * const palette = createTokenColors({ series: ['chart1', 'chart2'], ink: 'text' }, { element: () => node });
121
+ * $effect(() => chart?.setOption(build(palette.colors)));
122
+ * ```
123
+ */
124
+ export function createTokenColors(spec, options = {}) {
125
+ const target = () => options.element?.() ?? (typeof document === 'undefined' ? undefined : document.documentElement);
126
+ const compute = () => {
127
+ const element = target();
128
+ const scheme = colorSchemeOf(element);
129
+ const read = (role) => (element && readRoleColor(element, role)) || resolveRoles({ role }, scheme).role;
130
+ return { scheme, colors: resolveRoles(spec, scheme, read) };
131
+ };
132
+ const initial = compute();
133
+ let colors = $state.raw(initial.colors);
134
+ let scheme = $state(initial.scheme);
135
+ let version = $state(0);
136
+ const refresh = () => {
137
+ const next = compute();
138
+ scheme = next.scheme;
139
+ if (JSON.stringify(next.colors) === JSON.stringify(colors))
140
+ return;
141
+ colors = next.colors;
142
+ version += 1;
143
+ };
144
+ const unsubscribe = subscribe(refresh);
145
+ const destroy = () => unsubscribe();
146
+ try {
147
+ onDestroy(destroy);
148
+ }
149
+ catch {
150
+ /* module scope: the caller owns `destroy()` */
151
+ }
152
+ return {
153
+ get colors() {
154
+ return colors;
155
+ },
156
+ get scheme() {
157
+ return scheme;
158
+ },
159
+ get version() {
160
+ return version;
161
+ },
162
+ refresh,
163
+ destroy,
164
+ };
165
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loidolt/theme-svelte",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Accessible Svelte 5 components for Loidolt applications",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,8 +31,8 @@
31
31
  "clean": "rimraf dist"
32
32
  },
33
33
  "dependencies": {
34
- "@loidolt/theme-styles": "^0.6.0",
35
- "@loidolt/theme-tokens": "^0.6.0",
34
+ "@loidolt/theme-styles": "^0.7.0",
35
+ "@loidolt/theme-tokens": "^0.7.0",
36
36
  "bits-ui": "^2.19.0"
37
37
  },
38
38
  "peerDependencies": {