@su-labs/core 0.0.0-stage → 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2026-11-04
9
+
10
+ ### Added
11
+ - `createSuStore`: minimal reactive store compatible with the Svelte store contract and React `useSyncExternalStore`
12
+ - `isBrowser`: environment check used to keep utilities safe in Node and during SSR
13
+ - `@su-labs/core/theme`: framework-agnostic `SuThemeController` (moved from `@su-labs/theme`)
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Yevheniy Alekseyev <yev.alekseyev@gmail.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,117 @@
1
- # Temporary Holding Version
1
+ # @su-labs/core
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ ![npm](https://img.shields.io/npm/v/@su-labs/core?style=for-the-badge)
4
+ ![License](https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge)
5
+
6
+ Framework-agnostic browser utilities with a tiny reactive store. Use them from vanilla JavaScript, React, Svelte, Angular or any other framework. No dependencies besides `tslib`.
7
+
8
+ > Using Angular? [@su-labs/theme](https://www.npmjs.com/package/@su-labs/theme) wraps these utilities in Angular services with signals.
9
+
10
+ ## Table of Contents
11
+
12
+ - [Installation](#installation)
13
+ - [Getting Started](#getting-started)
14
+ - [Entry Points](#entry-points)
15
+ - [Reactive Stores](#reactive-stores)
16
+ - [Theme](#theme)
17
+ - [License](#license)
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ npm install @su-labs/core
23
+ ```
24
+
25
+ ## Getting Started
26
+
27
+ Pick the guide for your environment. Each one covers installation, setup and usage:
28
+
29
+ | Environment | Guide |
30
+ |-------------|-------|
31
+ | Vanilla JavaScript (bundler or CDN, no build step) | [Vanilla JS guide](https://github.com/Greezaaa/settings-utils-lib/blob/main/docs/guides/vanilla.md) |
32
+ | React (Vite, Next.js, Remix, …) | [React guide](https://github.com/Greezaaa/settings-utils-lib/blob/main/docs/guides/react.md) |
33
+ | Svelte and SvelteKit | [Svelte guide](https://github.com/Greezaaa/settings-utils-lib/blob/main/docs/guides/svelte.md) |
34
+ | Angular | [Angular guide](https://github.com/Greezaaa/settings-utils-lib/blob/main/docs/guides/angular.md) (uses the Angular packages) |
35
+ | Node.js and server-side rendering | [Node guide](https://github.com/Greezaaa/settings-utils-lib/blob/main/docs/guides/node.md) |
36
+
37
+ ## Entry Points
38
+
39
+ Each utility has its own entry point, so your bundle only includes what you import.
40
+
41
+ | Import | Contents |
42
+ |--------|----------|
43
+ | `@su-labs/core` | `createSuStore`, `isBrowser` |
44
+ | `@su-labs/core/theme` | `SuThemeController` and theme types |
45
+
46
+ ## Reactive Stores
47
+
48
+ Every utility exposes its state as a store with two methods:
49
+
50
+ | Method | Description |
51
+ |--------|-------------|
52
+ | `get()` | Returns the current value |
53
+ | `subscribe(listener)` | Calls `listener` immediately with the current value, then on every change. Returns an unsubscribe function. |
54
+
55
+ This shape follows the Svelte store contract and plugs directly into React's `useSyncExternalStore`, so no adapter library is needed.
56
+
57
+ You can create your own stores too:
58
+
59
+ ```ts
60
+ import { createSuStore } from '@su-labs/core';
61
+
62
+ const counter = createSuStore(0);
63
+ const unsubscribe = counter.subscribe((value) => console.log(value)); // logs 0
64
+ counter.set(1); // logs 1
65
+ unsubscribe();
66
+ ```
67
+
68
+ ## Theme
69
+
70
+ Manages light, dark, contrast and custom themes, follows the operating system's `prefers-color-scheme` when set to `'system'`, applies a `data-theme` attribute and CSS variables to `<html>`, and saves the user's choice in `localStorage`.
71
+
72
+ ```ts
73
+ import { SuThemeController } from '@su-labs/core/theme';
74
+
75
+ const theme = new SuThemeController();
76
+ theme.init({ defaultTheme: 'system' });
77
+
78
+ theme.theme.subscribe((name) => console.log('Active theme:', name));
79
+ theme.setTheme('dark');
80
+ ```
81
+
82
+ ### API
83
+
84
+ | Member | Description |
85
+ |--------|-------------|
86
+ | `init(config?)` | Applies configuration and the saved or default theme |
87
+ | `setTheme(theme, persist = true)` | Sets `'light'`, `'dark'`, `'contrast'`, `'custom'` or `'system'` |
88
+ | `getTheme()` | Returns the resolved theme (never `'system'`) |
89
+ | `getUserPreference()` | Returns the user's choice (may be `'system'`) |
90
+ | `theme` | Read-only store of the resolved theme |
91
+ | `destroy()` | Stops listening to system theme changes |
92
+
93
+ ### Configuration
94
+
95
+ | Option | Default | Description |
96
+ |--------|---------|-------------|
97
+ | `defaultTheme` | `'system'` | Theme used when nothing is saved |
98
+ | `storageKey` | `'su:theme'` | `localStorage` key |
99
+ | `cssVarPrefix` | `'--su-theme-'` | Prefix for the CSS variables |
100
+ | `themes` | — | Per-theme CSS variable overrides, e.g. `{ dark: { background: '#000' } }` |
101
+
102
+ ### CSS Variables
103
+
104
+ ```css
105
+ body {
106
+ color: var(--su-theme-color);
107
+ background: var(--su-theme-background);
108
+ }
109
+
110
+ a {
111
+ color: var(--su-theme-main-accent);
112
+ }
113
+ ```
114
+
115
+ ## License
116
+
117
+ MIT © 2025 [Yevheniy Alekseyev](https://github.com/Greezaaa)
@@ -0,0 +1,96 @@
1
+ import { createSuStore, isBrowser } from "@su-labs/core";
2
+ const DARK_SCHEME_QUERY = "(prefers-color-scheme: dark)";
3
+ const DEFAULT_THEMES = {
4
+ light: {
5
+ color: "#333",
6
+ background: "#fff",
7
+ "main-accent": "#007bff",
8
+ "second-accent": "#6c757d",
9
+ "third-accent": "#28a745"
10
+ },
11
+ dark: {
12
+ color: "#f0f0f0",
13
+ background: "#1a1a1a",
14
+ "main-accent": "#38a8e1",
15
+ "second-accent": "#a8b0b5",
16
+ "third-accent": "#2ee057"
17
+ },
18
+ contrast: {
19
+ color: "#000",
20
+ background: "#fff",
21
+ "main-accent": "#ff0000"
22
+ },
23
+ custom: {},
24
+ system: {}
25
+ };
26
+ var SuThemeController = class {
27
+ config = {};
28
+ cssVarPrefix = "--su-theme-";
29
+ storageKey = "su:theme";
30
+ userPreference = "light";
31
+ themeStore = createSuStore("light");
32
+ theme = this.themeStore.asReadonly();
33
+ systemChangeHandler = null;
34
+ mediaQuery;
35
+ init(config) {
36
+ this.config = {
37
+ ...this.config,
38
+ ...config
39
+ };
40
+ this.cssVarPrefix = this.config.cssVarPrefix ?? this.cssVarPrefix;
41
+ this.storageKey = this.config.storageKey ?? this.storageKey;
42
+ const savedPreference = isBrowser() ? localStorage.getItem(this.storageKey) : null;
43
+ this.userPreference = savedPreference ?? this.config.defaultTheme ?? "system";
44
+ this.setTheme(this.userPreference, false);
45
+ }
46
+ getTheme() {
47
+ return this.themeStore.get();
48
+ }
49
+ getUserPreference() {
50
+ return this.userPreference;
51
+ }
52
+ setTheme(theme, persist = true) {
53
+ if (theme !== "system") this.stopListeningToSystemChanges();
54
+ this.userPreference = theme;
55
+ this.applyResolvedTheme(theme === "system" ? this.resolveSystemTheme() : theme);
56
+ if (persist && isBrowser()) localStorage.setItem(this.storageKey, theme);
57
+ if (theme === "system") this.listenToSystemChanges();
58
+ }
59
+ destroy() {
60
+ this.stopListeningToSystemChanges();
61
+ }
62
+ applyResolvedTheme(theme) {
63
+ this.themeStore.set(theme);
64
+ if (!isBrowser()) return;
65
+ document.documentElement.setAttribute("data-theme", theme);
66
+ this.applyThemeVariables(theme);
67
+ }
68
+ resolveSystemTheme() {
69
+ return isBrowser() && window.matchMedia(DARK_SCHEME_QUERY).matches ? "dark" : "light";
70
+ }
71
+ listenToSystemChanges() {
72
+ if (this.mediaQuery || !isBrowser()) return;
73
+ this.mediaQuery = window.matchMedia(DARK_SCHEME_QUERY);
74
+ this.systemChangeHandler = (event) => {
75
+ if (this.userPreference === "system") this.applyResolvedTheme(event.matches ? "dark" : "light");
76
+ };
77
+ this.mediaQuery.addEventListener("change", this.systemChangeHandler);
78
+ }
79
+ stopListeningToSystemChanges() {
80
+ if (this.mediaQuery && this.systemChangeHandler) {
81
+ this.mediaQuery.removeEventListener("change", this.systemChangeHandler);
82
+ this.systemChangeHandler = null;
83
+ this.mediaQuery = void 0;
84
+ }
85
+ }
86
+ applyThemeVariables(theme) {
87
+ const variablesToApply = {
88
+ ...DEFAULT_THEMES[theme],
89
+ ...this.config.themes?.[theme] ?? {}
90
+ };
91
+ for (const [key, value] of Object.entries(variablesToApply)) if (value) document.documentElement.style.setProperty(`${this.cssVarPrefix}${key}`, value);
92
+ }
93
+ };
94
+ export { SuThemeController };
95
+
96
+ //# sourceMappingURL=su-labs-core-theme.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"su-labs-core-theme.mjs","names":[],"sources":["../../../../projects/su-labs/core/theme/src/lib/theme.controller.ts"],"sourcesContent":["import { createSuStore, isBrowser, SuReadableStore } from '@su-labs/core';\nimport { SuThemeConfig, SuThemeName, ThemeCssVariables } from './theme.types';\n\n/** Media query used to detect the operating system's dark mode */\nconst DARK_SCHEME_QUERY = '(prefers-color-scheme: dark)';\n\n/** Default CSS variables applied for each theme unless overridden in the config */\nconst DEFAULT_THEMES: Record<SuThemeName, ThemeCssVariables> = {\n light: {\n color: '#333',\n background: '#fff',\n 'main-accent': '#007bff',\n 'second-accent': '#6c757d',\n 'third-accent': '#28a745',\n },\n dark: {\n color: '#f0f0f0',\n background: '#1a1a1a',\n 'main-accent': '#38a8e1',\n 'second-accent': '#a8b0b5',\n 'third-accent': '#2ee057',\n },\n contrast: {\n color: '#000',\n background: '#fff',\n 'main-accent': '#ff0000',\n },\n custom: {},\n system: {},\n};\n\n/**\n * Framework-agnostic theme manager.\n *\n * Resolves the active theme (including 'system'), applies it to the document as a\n * `data-theme` attribute and CSS variables, persists the user's choice in localStorage,\n * and exposes the resolved theme as a reactive store.\n * Safe to import in Node: browser-only work is skipped when there is no DOM.\n */\nexport class SuThemeController {\n /** Configuration for the theme, provided during initialization */\n private config: SuThemeConfig = {};\n\n /** CSS variable prefix, can be customized */\n private cssVarPrefix = '--su-theme-';\n\n /** localStorage key for storing user preference, can be customized */\n private storageKey = 'su:theme';\n\n /** User selected theme (can be 'system') */\n private userPreference: SuThemeName = 'light';\n\n /** Current resolved theme ('light' | 'dark' | 'contrast' | 'custom') */\n private readonly themeStore = createSuStore<SuThemeName>('light');\n\n /** Reactive, read-only access to the resolved theme */\n readonly theme: SuReadableStore<SuThemeName> = this.themeStore.asReadonly();\n\n /** System media query listener callback */\n private systemChangeHandler: ((event: MediaQueryListEvent) => void) | null = null;\n\n /** MediaQueryList instance for system theme changes */\n private mediaQuery?: MediaQueryList;\n\n /**\n * Initialize with optional configuration and apply the saved or default theme.\n * @param config Optional configuration object.\n */\n public init(config?: SuThemeConfig): void {\n this.config = { ...this.config, ...config };\n this.cssVarPrefix = this.config.cssVarPrefix ?? this.cssVarPrefix;\n this.storageKey = this.config.storageKey ?? this.storageKey;\n\n const savedPreference = isBrowser() ? (localStorage.getItem(this.storageKey) as SuThemeName | null) : null;\n this.userPreference = savedPreference ?? this.config.defaultTheme ?? 'system';\n\n this.setTheme(this.userPreference, false);\n }\n\n /**\n * Get the currently active, resolved theme.\n * @returns The resolved theme name ('light', 'dark', 'contrast', 'custom').\n */\n public getTheme(): SuThemeName {\n return this.themeStore.get();\n }\n\n /**\n * Get the user's theme preference (may be 'system').\n * @returns The user preference ('light', 'dark', 'contrast', 'custom', 'system').\n */\n public getUserPreference(): SuThemeName {\n return this.userPreference;\n }\n\n /**\n * Set a new theme.\n * @param theme The theme to set ('light', 'dark', 'contrast', 'custom', 'system').\n * @param persist Whether to store the preference in localStorage (default: true).\n */\n public setTheme(theme: SuThemeName, persist: boolean = true): void {\n if (theme !== 'system') {\n this.stopListeningToSystemChanges();\n }\n\n this.userPreference = theme;\n this.applyResolvedTheme(theme === 'system' ? this.resolveSystemTheme() : theme);\n\n if (persist && isBrowser()) {\n localStorage.setItem(this.storageKey, theme);\n }\n\n if (theme === 'system') {\n this.listenToSystemChanges();\n }\n }\n\n /** Stop listening to system theme changes and release browser resources */\n public destroy(): void {\n this.stopListeningToSystemChanges();\n }\n\n /**\n * Update the store and apply the theme to the document.\n * @param theme The resolved theme to apply.\n */\n private applyResolvedTheme(theme: SuThemeName): void {\n this.themeStore.set(theme);\n\n if (!isBrowser()) {\n return;\n }\n\n document.documentElement.setAttribute('data-theme', theme);\n this.applyThemeVariables(theme);\n }\n\n /**\n * Resolve the current system theme using prefers-color-scheme.\n * @returns 'dark' if system prefers dark mode, otherwise 'light'.\n */\n private resolveSystemTheme(): SuThemeName {\n return isBrowser() && window.matchMedia(DARK_SCHEME_QUERY).matches ? 'dark' : 'light';\n }\n\n /**\n * Listen to system theme changes and automatically update the theme.\n * Only active when user preference is 'system'.\n */\n private listenToSystemChanges(): void {\n if (this.mediaQuery || !isBrowser()) {\n return;\n }\n\n this.mediaQuery = window.matchMedia(DARK_SCHEME_QUERY);\n this.systemChangeHandler = (event: MediaQueryListEvent): void => {\n if (this.userPreference === 'system') {\n this.applyResolvedTheme(event.matches ? 'dark' : 'light');\n }\n };\n this.mediaQuery.addEventListener('change', this.systemChangeHandler);\n }\n\n /** Stop listening to system theme changes */\n private stopListeningToSystemChanges(): void {\n if (this.mediaQuery && this.systemChangeHandler) {\n this.mediaQuery.removeEventListener('change', this.systemChangeHandler);\n this.systemChangeHandler = null;\n this.mediaQuery = undefined;\n }\n }\n\n /**\n * Apply CSS variables for the given theme, merging defaults with config overrides.\n * @param theme The theme whose variables should be applied.\n */\n private applyThemeVariables(theme: SuThemeName): void {\n const variablesToApply = {\n ...DEFAULT_THEMES[theme],\n ...(this.config.themes?.[theme] ?? {}),\n };\n\n for (const [key, value] of Object.entries(variablesToApply)) {\n if (value) {\n document.documentElement.style.setProperty(`${this.cssVarPrefix}${key}`, value);\n }\n }\n }\n}\n"],"mappings":";AAIA,MAAM,oBAAoB;AAG1B,MAAM,iBAAyD;CAC7D,OAAO;EACL,OAAO;EACP,YAAY;EACZ,eAAe;EACf,iBAAiB;EACjB,gBAAgB;CACjB;CACD,MAAM;EACJ,OAAO;EACP,YAAY;EACZ,eAAe;EACf,iBAAiB;EACjB,gBAAgB;CACjB;CACD,UAAU;EACR,OAAO;EACP,YAAY;EACZ,eAAe;CAChB;CACD,QAAQ,CAAA;CACR,QAAQ,CAAA;AACT;AAUD,IAAa,oBAAb,MAA8B;CAEpB,SAAwB,CAAA;CAGxB,eAAe;CAGf,aAAa;CAGb,iBAA8B;CAGrB,aAAa,cAA2B,OAAO;CAGvD,QAAsC,KAAK,WAAW,WAAU;CAGjE,sBAAqE;CAGrE;CAMD,KAAK,QAAsB;EAChC,KAAK,SAAS;GAAE,GAAG,KAAK;GAAQ,GAAG;EAAM;EACzC,KAAK,eAAe,KAAK,OAAO,gBAAgB,KAAK;EACrD,KAAK,aAAa,KAAK,OAAO,cAAc,KAAK;EAEjD,MAAM,kBAAkB,UAAS,IAAM,aAAa,QAAQ,KAAK,UAAU,IAA2B;EACtG,KAAK,iBAAiB,mBAAmB,KAAK,OAAO,gBAAgB;EAErE,KAAK,SAAS,KAAK,gBAAgB,KAAK;CAC1C;CAMO,WAAQ;EACb,OAAO,KAAK,WAAW,IAAG;CAC5B;CAMO,oBAAiB;EACtB,OAAO,KAAK;CACd;CAOO,SAAS,OAAoB,UAAmB,MAAI;EACzD,IAAI,UAAU,UACZ,KAAK,6BAA4B;EAGnC,KAAK,iBAAiB;EACtB,KAAK,mBAAmB,UAAU,WAAW,KAAK,mBAAkB,IAAK,KAAK;EAE9E,IAAI,WAAW,UAAS,GACtB,aAAa,QAAQ,KAAK,YAAY,KAAK;EAG7C,IAAI,UAAU,UACZ,KAAK,sBAAqB;CAE9B;CAGO,UAAO;EACZ,KAAK,6BAA4B;CACnC;CAMQ,mBAAmB,OAAkB;EAC3C,KAAK,WAAW,IAAI,KAAK;EAEzB,IAAI,CAAC,UAAS,GACZ;EAGF,SAAS,gBAAgB,aAAa,cAAc,KAAK;EACzD,KAAK,oBAAoB,KAAK;CAChC;CAMQ,qBAAkB;EACxB,OAAO,UAAS,KAAM,OAAO,WAAW,iBAAiB,CAAC,CAAC,UAAU,SAAS;CAChF;CAMQ,wBAAqB;EAC3B,IAAI,KAAK,cAAc,CAAC,UAAS,GAC/B;EAGF,KAAK,aAAa,OAAO,WAAW,iBAAiB;EACrD,KAAK,uBAAuB,UAAoC;GAC9D,IAAI,KAAK,mBAAmB,UAC1B,KAAK,mBAAmB,MAAM,UAAU,SAAS,OAAO;EAE5D;EACA,KAAK,WAAW,iBAAiB,UAAU,KAAK,mBAAmB;CACrE;CAGQ,+BAA4B;EAClC,IAAI,KAAK,cAAc,KAAK,qBAAqB;GAC/C,KAAK,WAAW,oBAAoB,UAAU,KAAK,mBAAmB;GACtE,KAAK,sBAAsB;GAC3B,KAAK,aAAa;EACpB;CACF;CAMQ,oBAAoB,OAAkB;EAC5C,MAAM,mBAAmB;GACvB,GAAG,eAAe;GAClB,GAAI,KAAK,OAAO,SAAS,UAAU,CAAA;EACpC;EAED,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,gBAAgB,GACxD,IAAI,OACF,SAAS,gBAAgB,MAAM,YAAY,GAAG,KAAK,eAAe,OAAO,KAAK;CAGpF;AACD"}
@@ -0,0 +1,27 @@
1
+ function isBrowser() {
2
+ return typeof window !== "undefined" && typeof document !== "undefined";
3
+ }
4
+ function createSuStore(initialValue) {
5
+ let value = initialValue;
6
+ const listeners = /* @__PURE__ */ new Set();
7
+ const readonlyStore = {
8
+ get: () => value,
9
+ subscribe: (listener) => {
10
+ listeners.add(listener);
11
+ listener(value);
12
+ return () => listeners.delete(listener);
13
+ }
14
+ };
15
+ return {
16
+ ...readonlyStore,
17
+ set: (nextValue) => {
18
+ if (Object.is(nextValue, value)) return;
19
+ value = nextValue;
20
+ listeners.forEach((listener) => listener(value));
21
+ },
22
+ asReadonly: () => readonlyStore
23
+ };
24
+ }
25
+ export { createSuStore, isBrowser };
26
+
27
+ //# sourceMappingURL=su-labs-core.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"su-labs-core.mjs","names":[],"sources":["../../../../projects/su-labs/core/src/lib/environment.ts","../../../../projects/su-labs/core/src/lib/store.ts"],"sourcesContent":["/**\n * Whether the code runs in a browser with DOM access.\n * Used to keep utilities safe to import in Node and during server-side rendering.\n */\nexport function isBrowser(): boolean {\n return typeof window !== 'undefined' && typeof document !== 'undefined';\n}\n","/** Callback invoked with the store's current value */\nexport type SuStoreListener<T> = (value: T) => void;\n\n/** Function returned by `subscribe` that stops the subscription */\nexport type SuUnsubscribe = () => void;\n\n/**\n * Read-only reactive value.\n *\n * `subscribe` calls the listener immediately with the current value and again on every change,\n * which matches the Svelte store contract and works with React's `useSyncExternalStore`.\n */\nexport interface SuReadableStore<T> {\n /** Get the current value */\n get(): T;\n\n /**\n * Listen to value changes.\n * @param listener Called immediately with the current value, then on every change.\n * @returns Function that removes the listener.\n */\n subscribe(listener: SuStoreListener<T>): SuUnsubscribe;\n}\n\n/** Reactive value that can be updated */\nexport interface SuWritableStore<T> extends SuReadableStore<T> {\n /**\n * Replace the current value and notify listeners if it changed.\n * @param value The new value.\n */\n set(value: T): void;\n\n /** Read-only view of this store, safe to expose publicly */\n asReadonly(): SuReadableStore<T>;\n}\n\n/**\n * Create a minimal reactive store with no framework dependencies.\n * @param initialValue The starting value.\n * @returns A writable store.\n */\nexport function createSuStore<T>(initialValue: T): SuWritableStore<T> {\n let value = initialValue;\n const listeners = new Set<SuStoreListener<T>>();\n\n const readonlyStore: SuReadableStore<T> = {\n get: () => value,\n subscribe: (listener) => {\n listeners.add(listener);\n listener(value);\n return () => listeners.delete(listener);\n },\n };\n\n return {\n ...readonlyStore,\n set: (nextValue) => {\n if (Object.is(nextValue, value)) {\n return;\n }\n value = nextValue;\n listeners.forEach((listener) => listener(value));\n },\n asReadonly: () => readonlyStore,\n };\n}\n"],"mappings":"AAIA,SAAgB,YAAS;CACvB,OAAO,OAAO,WAAW,eAAe,OAAO,aAAa;AAC9D;ACmCA,SAAgB,cAAiB,cAAe;CAC9C,IAAI,QAAQ;CACZ,MAAM,4BAAY,IAAI,IAAG;CAEzB,MAAM,gBAAoC;EACxC,WAAW;EACX,YAAY,aAAY;GACtB,UAAU,IAAI,QAAQ;GACtB,SAAS,KAAK;GACd,aAAa,UAAU,OAAO,QAAQ;EACxC;CACD;CAED,OAAO;EACL,GAAG;EACH,MAAM,cAAa;GACjB,IAAI,OAAO,GAAG,WAAW,KAAK,GAC5B;GAEF,QAAQ;GACR,UAAU,SAAS,aAAa,SAAS,KAAK,CAAC;EACjD;EACA,kBAAkB;CACnB;AACH"}
package/package.json CHANGED
@@ -1,6 +1,53 @@
1
1
  {
2
2
  "name": "@su-labs/core",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Framework-agnostic browser utilities (theme, and more to come) with a tiny reactive store. Works with vanilla JS, React, Svelte, Angular or any other framework.",
5
+ "keywords": [
6
+ "framework-agnostic",
7
+ "vanilla-js",
8
+ "react",
9
+ "svelte",
10
+ "angular",
11
+ "theme",
12
+ "dark-mode",
13
+ "css-variables",
14
+ "store",
15
+ "reactive",
16
+ "prefers-color-scheme"
17
+ ],
18
+ "homepage": "https://github.com/Greezaaa/settings-utils-lib/tree/main/projects/su-labs/core#readme",
19
+ "author": {
20
+ "name": "Yevheniy Alekseyev",
21
+ "email": "yev.alekseyev@gmail.com",
22
+ "url": "https://github.com/Greezaaa"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/Greezaaa/settings-utils-lib.git",
27
+ "directory": "projects/su-labs/core"
28
+ },
29
+ "bugs": {
30
+ "url": "https://github.com/Greezaaa/settings-utils-lib/issues"
31
+ },
32
+ "dependencies": {
33
+ "tslib": "^2.3.0"
34
+ },
35
+ "sideEffects": false,
36
+ "license": "MIT",
37
+ "module": "fesm2022/su-labs-core.mjs",
38
+ "typings": "types/su-labs-core.d.ts",
39
+ "exports": {
40
+ "./package.json": {
41
+ "default": "./package.json"
42
+ },
43
+ ".": {
44
+ "types": "./types/su-labs-core.d.ts",
45
+ "default": "./fesm2022/su-labs-core.mjs"
46
+ },
47
+ "./theme": {
48
+ "types": "./types/su-labs-core-theme.d.ts",
49
+ "default": "./fesm2022/su-labs-core-theme.mjs"
50
+ }
51
+ },
52
+ "type": "module"
6
53
  }
@@ -0,0 +1,94 @@
1
+ import { SuReadableStore } from "@su-labs/core";
2
+ /** Available theme names */
3
+ export type SuThemeName = 'light' | 'dark' | 'contrast' | 'custom' | 'system';
4
+ /** Theme configuration */
5
+ export interface SuThemeConfig {
6
+ /** localStorage key for storing selected theme */
7
+ storageKey?: string;
8
+ /** CSS variable prefix, applied to all theme variables */
9
+ cssVarPrefix?: string;
10
+ /** Default theme if nothing saved in localStorage */
11
+ defaultTheme?: SuThemeName;
12
+ /** Optional CSS variable overrides for specific themes */
13
+ themes?: Partial<Record<SuThemeName, ThemeCssVariables>>;
14
+ }
15
+ /** Available CSS variables for theming */
16
+ export interface ThemeCssVariables {
17
+ color?: string;
18
+ background?: string;
19
+ 'main-accent'?: string;
20
+ 'second-accent'?: string;
21
+ 'third-accent'?: string;
22
+ [key: string]: string | undefined;
23
+ }
24
+ /**
25
+ * Framework-agnostic theme manager.
26
+ *
27
+ * Resolves the active theme (including 'system'), applies it to the document as a
28
+ * `data-theme` attribute and CSS variables, persists the user's choice in localStorage,
29
+ * and exposes the resolved theme as a reactive store.
30
+ * Safe to import in Node: browser-only work is skipped when there is no DOM.
31
+ */
32
+ export declare class SuThemeController {
33
+ /** Configuration for the theme, provided during initialization */
34
+ private config;
35
+ /** CSS variable prefix, can be customized */
36
+ private cssVarPrefix;
37
+ /** localStorage key for storing user preference, can be customized */
38
+ private storageKey;
39
+ /** User selected theme (can be 'system') */
40
+ private userPreference;
41
+ /** Current resolved theme ('light' | 'dark' | 'contrast' | 'custom') */
42
+ private readonly themeStore;
43
+ /** Reactive, read-only access to the resolved theme */
44
+ readonly theme: SuReadableStore<SuThemeName>;
45
+ /** System media query listener callback */
46
+ private systemChangeHandler;
47
+ /** MediaQueryList instance for system theme changes */
48
+ private mediaQuery?;
49
+ /**
50
+ * Initialize with optional configuration and apply the saved or default theme.
51
+ * @param config Optional configuration object.
52
+ */
53
+ init(config?: SuThemeConfig): void;
54
+ /**
55
+ * Get the currently active, resolved theme.
56
+ * @returns The resolved theme name ('light', 'dark', 'contrast', 'custom').
57
+ */
58
+ getTheme(): SuThemeName;
59
+ /**
60
+ * Get the user's theme preference (may be 'system').
61
+ * @returns The user preference ('light', 'dark', 'contrast', 'custom', 'system').
62
+ */
63
+ getUserPreference(): SuThemeName;
64
+ /**
65
+ * Set a new theme.
66
+ * @param theme The theme to set ('light', 'dark', 'contrast', 'custom', 'system').
67
+ * @param persist Whether to store the preference in localStorage (default: true).
68
+ */
69
+ setTheme(theme: SuThemeName, persist?: boolean): void;
70
+ /** Stop listening to system theme changes and release browser resources */
71
+ destroy(): void;
72
+ /**
73
+ * Update the store and apply the theme to the document.
74
+ * @param theme The resolved theme to apply.
75
+ */
76
+ private applyResolvedTheme;
77
+ /**
78
+ * Resolve the current system theme using prefers-color-scheme.
79
+ * @returns 'dark' if system prefers dark mode, otherwise 'light'.
80
+ */
81
+ private resolveSystemTheme;
82
+ /**
83
+ * Listen to system theme changes and automatically update the theme.
84
+ * Only active when user preference is 'system'.
85
+ */
86
+ private listenToSystemChanges;
87
+ /** Stop listening to system theme changes */
88
+ private stopListeningToSystemChanges;
89
+ /**
90
+ * Apply CSS variables for the given theme, merging defaults with config overrides.
91
+ * @param theme The theme whose variables should be applied.
92
+ */
93
+ private applyThemeVariables;
94
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Whether the code runs in a browser with DOM access.
3
+ * Used to keep utilities safe to import in Node and during server-side rendering.
4
+ */
5
+ export declare function isBrowser(): boolean;
6
+ /** Callback invoked with the store's current value */
7
+ export type SuStoreListener<T> = (value: T) => void;
8
+ /** Function returned by `subscribe` that stops the subscription */
9
+ export type SuUnsubscribe = () => void;
10
+ /**
11
+ * Read-only reactive value.
12
+ *
13
+ * `subscribe` calls the listener immediately with the current value and again on every change,
14
+ * which matches the Svelte store contract and works with React's `useSyncExternalStore`.
15
+ */
16
+ export interface SuReadableStore<T> {
17
+ /** Get the current value */
18
+ get(): T;
19
+ /**
20
+ * Listen to value changes.
21
+ * @param listener Called immediately with the current value, then on every change.
22
+ * @returns Function that removes the listener.
23
+ */
24
+ subscribe(listener: SuStoreListener<T>): SuUnsubscribe;
25
+ }
26
+ /** Reactive value that can be updated */
27
+ export interface SuWritableStore<T> extends SuReadableStore<T> {
28
+ /**
29
+ * Replace the current value and notify listeners if it changed.
30
+ * @param value The new value.
31
+ */
32
+ set(value: T): void;
33
+ /** Read-only view of this store, safe to expose publicly */
34
+ asReadonly(): SuReadableStore<T>;
35
+ }
36
+ /**
37
+ * Create a minimal reactive store with no framework dependencies.
38
+ * @param initialValue The starting value.
39
+ * @returns A writable store.
40
+ */
41
+ export declare function createSuStore<T>(initialValue: T): SuWritableStore<T>;