@loidolt/theme-svelte 0.6.0 → 0.8.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,62 @@
1
+ <script lang="ts" generics="P extends string = string">
2
+ import { builtInPresets } from '@loidolt/theme-tokens';
3
+ import type { ControlSize, Option } from '../types.js';
4
+ import type { Theme } from '../theme.svelte.js';
5
+ import Select from './Select.svelte';
6
+ import ToggleGroup from './ToggleGroup.svelte';
7
+
8
+ interface Props {
9
+ /** The store from `createTheme({ presets })`. */
10
+ theme: Theme<P>;
11
+ /**
12
+ * Label per preset. Defaults to the built-in presets' own labels, and to the capitalised
13
+ * name for any preset of your own.
14
+ */
15
+ options?: Option<P>[];
16
+ /** Accessible name of the control. */
17
+ label?: string;
18
+ /** `segmented` keeps every preset in view; `select` folds a longer list into a dropdown. */
19
+ variant?: 'segmented' | 'select';
20
+ size?: ControlSize;
21
+ class?: string;
22
+ }
23
+
24
+ let {
25
+ theme,
26
+ options,
27
+ label = 'Style',
28
+ variant = 'segmented',
29
+ size = 'sm',
30
+ class: className,
31
+ }: Props = $props();
32
+
33
+ const builtInLabels = new Map<string, string>(
34
+ builtInPresets.map((preset) => [preset.name, preset.label])
35
+ );
36
+
37
+ const items = $derived(
38
+ options ??
39
+ theme.presets.map((name) => ({
40
+ value: name,
41
+ label: builtInLabels.get(name) ?? name.charAt(0).toUpperCase() + name.slice(1),
42
+ }))
43
+ );
44
+
45
+ // Bound through the theme rather than passed down: a segmented control lets the pressed item
46
+ // be pressed again to clear it, and there is no "no preset". The theme ignores the empty
47
+ // value, and the getter hands the control the preset that is still in force.
48
+ const getPreset = () => theme.preset;
49
+ const setPreset = (next: P | P[] | '' | undefined) => {
50
+ if (typeof next === 'string' && next !== '') theme.preset = next;
51
+ };
52
+ </script>
53
+
54
+ <!--
55
+ The preset half of a theme switcher; ThemeToggle is the scheme half. Both drive the same
56
+ `createTheme()` store, so they can sit side by side in a topbar.
57
+ -->
58
+ {#if variant === 'select'}
59
+ <Select options={items} bind:value={getPreset, setPreset} {label} boxed class={className} />
60
+ {:else}
61
+ <ToggleGroup options={items} bind:value={getPreset, setPreset} {label} {size} class={className} />
62
+ {/if}
@@ -0,0 +1,40 @@
1
+ import type { ControlSize, Option } from '../types.js';
2
+ import type { Theme } from '../theme.svelte.js';
3
+ declare function $$render<P extends string = string>(): {
4
+ props: {
5
+ /** The store from `createTheme({ presets })`. */
6
+ theme: Theme<P>;
7
+ /**
8
+ * Label per preset. Defaults to the built-in presets' own labels, and to the capitalised
9
+ * name for any preset of your own.
10
+ */
11
+ options?: Option<P>[];
12
+ /** Accessible name of the control. */
13
+ label?: string;
14
+ /** `segmented` keeps every preset in view; `select` folds a longer list into a dropdown. */
15
+ variant?: "segmented" | "select";
16
+ size?: ControlSize;
17
+ class?: string;
18
+ };
19
+ exports: {};
20
+ bindings: "";
21
+ slots: {};
22
+ events: {};
23
+ };
24
+ declare class __sveltets_Render<P extends string = string> {
25
+ props(): ReturnType<typeof $$render<P>>['props'];
26
+ events(): ReturnType<typeof $$render<P>>['events'];
27
+ slots(): ReturnType<typeof $$render<P>>['slots'];
28
+ bindings(): "";
29
+ exports(): {};
30
+ }
31
+ interface $$IsomorphicComponent {
32
+ new <P extends string = string>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<P>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<P>['props']>, ReturnType<__sveltets_Render<P>['events']>, ReturnType<__sveltets_Render<P>['slots']>> & {
33
+ $$bindings?: ReturnType<__sveltets_Render<P>['bindings']>;
34
+ } & ReturnType<__sveltets_Render<P>['exports']>;
35
+ <P extends string = string>(internal: unknown, props: ReturnType<__sveltets_Render<P>['props']> & {}): ReturnType<__sveltets_Render<P>['exports']>;
36
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
37
+ }
38
+ declare const PresetPicker: $$IsomorphicComponent;
39
+ type PresetPicker<P extends string = string> = InstanceType<typeof PresetPicker<P>>;
40
+ export default PresetPicker;
@@ -51,6 +51,14 @@
51
51
  value = next as T | T[];
52
52
  onValueChange?.(next as T | T[]);
53
53
  };
54
+
55
+ /*
56
+ * Bound rather than passed, so Bits holds no copy of its own: whatever `value` reads after a
57
+ * press is what shows as pressed. A parent that refuses a change — PresetPicker refusing the
58
+ * empty value a second press on the pressed item produces — then keeps its item pressed.
59
+ */
60
+ const single = () => (value ?? '') as string;
61
+ const many = () => (value ?? []) as string[];
54
62
  </script>
55
63
 
56
64
  <!--
@@ -73,8 +81,7 @@
73
81
  <ToggleGroupPrimitive.Root
74
82
  bind:ref
75
83
  type="multiple"
76
- value={(value ?? []) as string[]}
77
- onValueChange={change}
84
+ bind:value={many, change}
78
85
  {orientation}
79
86
  {disabled}
80
87
  class={classes}
@@ -92,8 +99,7 @@
92
99
  <ToggleGroupPrimitive.Root
93
100
  bind:ref
94
101
  type="single"
95
- value={(value ?? '') as string}
96
- onValueChange={change}
102
+ bind:value={single, change}
97
103
  {orientation}
98
104
  {disabled}
99
105
  class={classes}
package/dist/index.d.ts CHANGED
@@ -50,6 +50,7 @@ export { default as Pagination } from './components/Pagination.svelte';
50
50
  export { default as Panel } from './components/Panel.svelte';
51
51
  export { default as PasswordInput } from './components/PasswordInput.svelte';
52
52
  export { default as Popover } from './components/Popover.svelte';
53
+ export { default as PresetPicker } from './components/PresetPicker.svelte';
53
54
  export { default as Progress } from './components/Progress.svelte';
54
55
  export { default as RadioGroup } from './components/RadioGroup.svelte';
55
56
  export { default as RecordStepper } from './components/RecordStepper.svelte';
@@ -105,6 +106,8 @@ export { strokeLength, strokePath, strokesToSvg, validateSignature } from './sig
105
106
  export type { SignatureError, SignaturePoint, SignatureStroke, SignatureValue, } from './signature.js';
106
107
  export { createDataTable } from './data-table.svelte.js';
107
108
  export type { DataTableColumn, DataTableFilterValue, DataTableOptions, DataTableRow, DataTableSort, DataTableState, } from './data-table.svelte.js';
109
+ export { colorSchemeOf, createTokenColors, normalizeColor, observeColorScheme, readRoleColor, } from './token-colors.svelte.js';
110
+ export type { TokenColors, TokenColorsOptions } from './token-colors.svelte.js';
108
111
  export { createAnnouncer } from './announcer.svelte.js';
109
112
  export type { Announcer, AnnouncerOptions, Politeness } from './announcer.svelte.js';
110
113
  export { createToaster } from './toaster.svelte.js';
package/dist/index.js CHANGED
@@ -50,6 +50,7 @@ export { default as Pagination } from './components/Pagination.svelte';
50
50
  export { default as Panel } from './components/Panel.svelte';
51
51
  export { default as PasswordInput } from './components/PasswordInput.svelte';
52
52
  export { default as Popover } from './components/Popover.svelte';
53
+ export { default as PresetPicker } from './components/PresetPicker.svelte';
53
54
  export { default as Progress } from './components/Progress.svelte';
54
55
  export { default as RadioGroup } from './components/RadioGroup.svelte';
55
56
  export { default as RecordStepper } from './components/RecordStepper.svelte';
@@ -96,6 +97,7 @@ export { canPlayHlsNatively, loadHls, setHlsLoader } from './hls.js';
96
97
  export { createHlsSource } from './hls.svelte.js';
97
98
  export { strokeLength, strokePath, strokesToSvg, validateSignature } from './signature.js';
98
99
  export { createDataTable } from './data-table.svelte.js';
100
+ export { colorSchemeOf, createTokenColors, normalizeColor, observeColorScheme, readRoleColor, } from './token-colors.svelte.js';
99
101
  export { createAnnouncer } from './announcer.svelte.js';
100
102
  export { createToaster } from './toaster.svelte.js';
101
103
  export { createTheme, themeScript } from './theme.svelte.js';
@@ -2,7 +2,7 @@
2
2
  export type ThemePreference = 'light' | 'dark' | 'system';
3
3
  /** What that choice resolves to right now — the value written to the DOM. */
4
4
  export type ColorScheme = 'light' | 'dark';
5
- export interface ThemeOptions {
5
+ export interface ThemeOptions<P extends string = string> {
6
6
  /** `localStorage` key holding the preference. Set to `null` to keep the choice in memory. */
7
7
  storageKey?: string | null;
8
8
  /** Attribute the resolved scheme is written to. Must match the stylesheet's selector. */
@@ -18,14 +18,34 @@ export interface ThemeOptions {
18
18
  * if separate windows should be able to hold different themes.
19
19
  */
20
20
  sync?: boolean;
21
+ /**
22
+ * The style presets the app ships — the names of the preset stylesheets it imports. Setting
23
+ * this turns on the preset axis: the chosen preset is stored and written to `presetAttribute`
24
+ * alongside the colour scheme. Leave it out and the preset attribute is never touched, so a
25
+ * static `<html data-preset="soft">` keeps working.
26
+ */
27
+ presets?: readonly P[];
28
+ /** Preset before anything is stored. Defaults to the first of `presets`. */
29
+ defaultPreset?: NoInfer<P>;
30
+ /** `localStorage` key holding the preset. Set to `null` to keep the choice in memory. */
31
+ presetStorageKey?: string | null;
32
+ /** Attribute the preset is written to. Must match the preset stylesheets' selector. */
33
+ presetAttribute?: string;
21
34
  }
22
- export interface Theme {
35
+ export interface Theme<P extends string = string> {
23
36
  /** The stored choice. Assignable: `theme.preference = 'dark'`. */
24
37
  preference: ThemePreference;
25
38
  /** The scheme in force — `preference`, or the system scheme when that is `system`. */
26
39
  readonly resolved: ColorScheme;
27
40
  /** What the system currently prefers, regardless of the choice. */
28
41
  readonly system: ColorScheme;
42
+ /**
43
+ * The style preset in force, or `undefined` when the theme was created without `presets`.
44
+ * Assignable: `theme.preset = 'soft'`; a name outside `presets` is ignored.
45
+ */
46
+ preset: P | undefined;
47
+ /** The presets this theme switches between, as given. */
48
+ readonly presets: readonly P[];
29
49
  /** Flips between explicit light and dark, starting from whatever is showing. */
30
50
  toggle(): void;
31
51
  /** Removes the media and storage listeners. Automatic for a component-scoped instance. */
@@ -52,8 +72,8 @@ export interface Theme {
52
72
  *
53
73
  * Pair it with {@link themeScript} in `app.html`, or the first paint of a reload is light.
54
74
  */
55
- export declare function createTheme(options?: ThemeOptions): Theme;
56
- export interface ThemeScriptOptions extends Pick<ThemeOptions, 'storageKey' | 'attribute' | 'defaultPreference' | 'defaultScheme'> {
75
+ export declare function createTheme<const P extends string = string>(options?: ThemeOptions<P>): Theme<P>;
76
+ export interface ThemeScriptOptions extends Pick<ThemeOptions, 'storageKey' | 'attribute' | 'defaultPreference' | 'defaultScheme' | 'presets' | 'defaultPreset' | 'presetStorageKey' | 'presetAttribute'> {
57
77
  /** Wrap the source in a `<script>` tag. Off by default, so it can go inside your own tag. */
58
78
  tag?: boolean;
59
79
  }
@@ -23,7 +23,11 @@ const isPreference = (value) => typeof value === 'string' && SCHEMES.includes(va
23
23
  * Pair it with {@link themeScript} in `app.html`, or the first paint of a reload is light.
24
24
  */
25
25
  export function createTheme(options = {}) {
26
- const { storageKey = 'loidolt-theme', attribute = 'data-theme', element, defaultPreference = 'system', defaultScheme = 'light', sync = true, } = options;
26
+ const { storageKey = 'loidolt-theme', attribute = 'data-theme', element, defaultPreference = 'system', defaultScheme = 'light', sync = true, presets = [], presetStorageKey = 'loidolt-preset', presetAttribute = 'data-preset', } = options;
27
+ const defaultPreset = options.defaultPreset ?? presets[0];
28
+ if (defaultPreset !== undefined && !presets.includes(defaultPreset)) {
29
+ throw new Error(`defaultPreset "${defaultPreset}" is not one of presets`);
30
+ }
27
31
  const browser = typeof document !== 'undefined';
28
32
  const target = element ?? (browser ? document.documentElement : null);
29
33
  const read = () => {
@@ -39,7 +43,22 @@ export function createTheme(options = {}) {
39
43
  return defaultPreference;
40
44
  }
41
45
  };
46
+ const isPreset = (value) => typeof value === 'string' && presets.includes(value);
47
+ const readPreset = () => {
48
+ if (defaultPreset === undefined)
49
+ return undefined;
50
+ if (!browser || presetStorageKey === null)
51
+ return defaultPreset;
52
+ try {
53
+ const stored = localStorage.getItem(presetStorageKey);
54
+ return isPreset(stored) ? stored : defaultPreset;
55
+ }
56
+ catch {
57
+ return defaultPreset; // see `read`
58
+ }
59
+ };
42
60
  let preference = $state(read());
61
+ let preset = $state(readPreset());
43
62
  let system = $state(defaultScheme);
44
63
  let media;
45
64
  const onSystemChange = (event) => {
@@ -54,35 +73,57 @@ export function createTheme(options = {}) {
54
73
  const resolve = () => (preference === 'system' ? system : preference);
55
74
  function apply() {
56
75
  target?.setAttribute(attribute, resolve());
76
+ if (preset !== undefined)
77
+ target?.setAttribute(presetAttribute, preset);
57
78
  }
58
79
  const onStorage = (event) => {
59
- if (event.key !== storageKey)
80
+ if (storageKey !== null && event.key === storageKey) {
81
+ // A cleared key means another tab reset to the default rather than picking a scheme.
82
+ preference = isPreference(event.newValue) ? event.newValue : defaultPreference;
83
+ }
84
+ else if (defaultPreset !== undefined &&
85
+ presetStorageKey !== null &&
86
+ event.key === presetStorageKey) {
87
+ preset = isPreset(event.newValue) ? event.newValue : defaultPreset;
88
+ }
89
+ else
60
90
  return;
61
- // A cleared key means another tab reset to the default rather than picking a scheme.
62
- preference = isPreference(event.newValue) ? event.newValue : defaultPreference;
63
91
  apply();
64
92
  };
65
- if (browser && sync && storageKey !== null)
93
+ const listens = browser &&
94
+ sync &&
95
+ (storageKey !== null || (defaultPreset !== undefined && presetStorageKey !== null));
96
+ if (listens)
66
97
  window.addEventListener('storage', onStorage);
67
98
  // Written on creation, not in an effect: `createTheme()` is meant to be callable at module
68
99
  // scope, where there is no reactive context to own an effect.
69
100
  apply();
101
+ const persist = (key, value) => {
102
+ if (!browser || key === null)
103
+ return;
104
+ try {
105
+ localStorage.setItem(key, value);
106
+ }
107
+ catch {
108
+ /* see `read`: unavailable storage degrades to a memory-only choice */
109
+ }
110
+ };
70
111
  function set(next) {
71
112
  preference = next;
72
- if (browser && storageKey !== null) {
73
- try {
74
- localStorage.setItem(storageKey, next);
75
- }
76
- catch {
77
- /* see `read`: unavailable storage degrades to a memory-only choice */
78
- }
79
- }
113
+ persist(storageKey, next);
114
+ apply();
115
+ }
116
+ function setPreset(next) {
117
+ if (defaultPreset === undefined || !isPreset(next))
118
+ return;
119
+ preset = next;
120
+ persist(presetStorageKey, next);
80
121
  apply();
81
122
  }
82
123
  const destroy = () => {
83
124
  media?.removeEventListener('change', onSystemChange);
84
125
  media = undefined;
85
- if (browser && sync && storageKey !== null)
126
+ if (listens)
86
127
  window.removeEventListener('storage', onStorage);
87
128
  };
88
129
  try {
@@ -104,6 +145,13 @@ export function createTheme(options = {}) {
104
145
  get system() {
105
146
  return system;
106
147
  },
148
+ get preset() {
149
+ return preset;
150
+ },
151
+ set preset(next) {
152
+ setPreset(next);
153
+ },
154
+ presets,
107
155
  toggle() {
108
156
  set(resolve() === 'dark' ? 'light' : 'dark');
109
157
  },
@@ -137,7 +185,7 @@ export function createTheme(options = {}) {
137
185
  * inline in an HTML `<script>` element without further escaping.
138
186
  */
139
187
  export function themeScript(options = {}) {
140
- const { storageKey = 'loidolt-theme', attribute = 'data-theme', defaultPreference = 'system', defaultScheme = 'light', tag = false, } = options;
188
+ const { storageKey = 'loidolt-theme', attribute = 'data-theme', defaultPreference = 'system', defaultScheme = 'light', presets = [], presetStorageKey = 'loidolt-preset', presetAttribute = 'data-preset', tag = false, } = options;
141
189
  // JSON string escaping alone leaves `<` intact, including a literal `</script>` that would end
142
190
  // an inline script element. Escaping HTML-significant characters and the two JavaScript line
143
191
  // separators keeps every option inside its string literal in both source-only and tagged forms.
@@ -151,6 +199,12 @@ export function themeScript(options = {}) {
151
199
  const attr = serialize(attribute);
152
200
  const fallback = serialize(defaultPreference);
153
201
  const scheme = serialize(defaultScheme);
202
+ // Only emitted when the preset axis is on, so the scheme-only script stays byte-identical.
203
+ const preset = presets.length
204
+ ? `try{var q=${serialize(options.defaultPreset ?? presets[0])},l=${serialize(presets)},k=${serialize(presetStorageKey)};` +
205
+ `try{var w=k===null?null:localStorage.getItem(k);if(l.indexOf(w)>-1)q=w;}catch(e){}` +
206
+ `document.documentElement.setAttribute(${serialize(presetAttribute)},q);}catch(e){}`
207
+ : '';
154
208
  const source = `(function(){try{` +
155
209
  `var p=${fallback};` +
156
210
  // Storage failure must not skip system resolution and attribute application.
@@ -161,6 +215,6 @@ export function themeScript(options = {}) {
161
215
  `document.documentElement.setAttribute(${attr},p==="system"?s:p);` +
162
216
  // A theme is cosmetic; a throw here would abort a blocking head script and take the page
163
217
  // down with it. Swallow and let the stylesheet's own default stand.
164
- `}catch(e){}})()`;
218
+ `}catch(e){}${preset}})()`;
165
219
  return tag ? `<script>${source}</script>` : source;
166
220
  }
@@ -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.8.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.8.0",
35
+ "@loidolt/theme-tokens": "^0.8.0",
36
36
  "bits-ui": "^2.19.0"
37
37
  },
38
38
  "peerDependencies": {