@pantoken/scope 0.3.11
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/LICENSE +21 -0
- package/dist/index.mjs +2 -0
- package/dist/scope.d.mts +179 -0
- package/dist/scope.mjs +2 -0
- package/dist/src-Daa7KXHA.mjs +416 -0
- package/package.json +51 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Danny Wahl
|
|
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/dist/index.mjs
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { _ as THEME_ATTR, a as createScope, b as schemeClass, c as resolveScheme, d as BOUNDARY_CLASS, f as COLOR_ATTR, g as SCOPE_ATTRS, h as SCHEME_ATTR, i as connectScope, l as resolveScope, m as INSTANCE_ATTR, n as ensureProperties, o as getScope, p as DEFAULT_INSTANCE, r as hasProperties, s as resolveInstance, t as observeScope, u as BOUNDARY_ATTR, v as colorClass, x as themeClass, y as isScheme } from "./src-Daa7KXHA.mjs";
|
|
2
|
+
export { BOUNDARY_ATTR, BOUNDARY_CLASS, COLOR_ATTR, DEFAULT_INSTANCE, INSTANCE_ATTR, SCHEME_ATTR, SCOPE_ATTRS, THEME_ATTR, colorClass, connectScope, createScope, ensureProperties, getScope, hasProperties, isScheme, observeScope, resolveInstance, resolveScheme, resolveScope, schemeClass, themeClass };
|
package/dist/scope.d.mts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { BOUNDARY_ATTR, BOUNDARY_CLASS, COLOR_ATTR, INSTANCE_ATTR, SCHEME_ATTR, SCOPE_ATTRS, Scheme, Scheme as Scheme$1, THEME_ATTR, colorClass, isScheme, schemeClass, themeClass } from "@pantoken/utils/scope";
|
|
2
|
+
//#region src/contract.d.ts
|
|
3
|
+
/** The resolvable state of a scope. Every field is optional — an unset field inherits. */
|
|
4
|
+
interface ScopeConfig {
|
|
5
|
+
/** Theme key, e.g. `"rebrand"`. Matched by `[data-pantoken-theme="…"]`. */
|
|
6
|
+
theme?: string;
|
|
7
|
+
/** Pinned color scheme. Omit to follow the OS preference. */
|
|
8
|
+
scheme?: Scheme$1;
|
|
9
|
+
/** Custom brand color key. */
|
|
10
|
+
color?: string;
|
|
11
|
+
/** Identifier of the pantoken instance that owns this scope. */
|
|
12
|
+
instanceId?: string;
|
|
13
|
+
}
|
|
14
|
+
/** A {@link ScopeConfig} resolved against the DOM, plus where each part came from. */
|
|
15
|
+
interface ResolvedScope extends ScopeConfig {
|
|
16
|
+
/** The nearest element that declared any scope attribute, or `null` when none did. */
|
|
17
|
+
element: Element | null;
|
|
18
|
+
/** True when resolution stopped at a `data-pantoken-boundary` element. */
|
|
19
|
+
bounded: boolean;
|
|
20
|
+
}
|
|
21
|
+
/** The default instance id, used when a scope does not name one. */
|
|
22
|
+
export declare const DEFAULT_INSTANCE = "pantoken";
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region src/resolve.d.ts
|
|
25
|
+
/**
|
|
26
|
+
* Resolve the scope in effect for `start` by walking up its ancestors.
|
|
27
|
+
*
|
|
28
|
+
* @param start - The element to resolve for. Its own attributes participate.
|
|
29
|
+
* @returns The {@link ResolvedScope}; fields no ancestor declared are left `undefined`.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```ts
|
|
33
|
+
* import { resolveScope } from "@pantoken/scope";
|
|
34
|
+
*
|
|
35
|
+
* // <div data-pantoken-theme="canvas"><span id="x"></span></div>
|
|
36
|
+
* resolveScope(document.querySelector("#x")!).theme; // "canvas"
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export declare function resolveScope(start: Element | null): ResolvedScope;
|
|
40
|
+
/**
|
|
41
|
+
* The instance that owns `start`, falling back to {@link DEFAULT_INSTANCE}.
|
|
42
|
+
*
|
|
43
|
+
* Use this to decide whether an event or message is yours before acting on it.
|
|
44
|
+
*/
|
|
45
|
+
export declare function resolveInstance(start: Element | null): string;
|
|
46
|
+
/**
|
|
47
|
+
* The effective color scheme for `start`: an explicit pin if any ancestor set one, otherwise the
|
|
48
|
+
* `color-scheme` the element actually computes to, otherwise the OS preference.
|
|
49
|
+
*
|
|
50
|
+
* Never reads `document.documentElement` — that global read is exactly what desynchronises two
|
|
51
|
+
* instances on one page.
|
|
52
|
+
*/
|
|
53
|
+
export declare function resolveScheme(start: Element | null): Scheme;
|
|
54
|
+
//#endregion
|
|
55
|
+
//#region src/scope.d.ts
|
|
56
|
+
/** The message envelope exchanged between a scope and its attached frames. */
|
|
57
|
+
interface ScopeMessage extends ScopeConfig {
|
|
58
|
+
type: "pantoken-scope" | "pantoken-scope-request";
|
|
59
|
+
instanceId: string;
|
|
60
|
+
}
|
|
61
|
+
/** Options for {@link createScope}. */
|
|
62
|
+
interface CreateScopeOptions extends ScopeConfig {
|
|
63
|
+
/**
|
|
64
|
+
* Identifier distinguishing this pantoken instance from others on the page. Scopes, storage keys,
|
|
65
|
+
* and frame messages are all keyed by it.
|
|
66
|
+
*/
|
|
67
|
+
instanceId?: string;
|
|
68
|
+
/**
|
|
69
|
+
* Stop ancestor resolution at this element, so nothing outside can theme what is inside. Use for
|
|
70
|
+
* a preview pane that must not inherit the surrounding app's theme.
|
|
71
|
+
*/
|
|
72
|
+
boundary?: boolean;
|
|
73
|
+
/** Persist and restore state. Pass `false` to keep the scope in-memory only (default `true`). */
|
|
74
|
+
persist?: boolean;
|
|
75
|
+
/** Storage backend (default `localStorage`, silently skipped when unavailable). */
|
|
76
|
+
storage?: Pick<Storage, "getItem" | "setItem"> | null;
|
|
77
|
+
}
|
|
78
|
+
/** A live scope bound to one element. */
|
|
79
|
+
interface PantokenScope {
|
|
80
|
+
/** The element carrying the scope attributes. */
|
|
81
|
+
readonly element: HTMLElement;
|
|
82
|
+
/** The instance this scope belongs to. */
|
|
83
|
+
readonly instanceId: string;
|
|
84
|
+
/** The scope's own configuration — what it declares, not what it inherits. */
|
|
85
|
+
get(): ScopeConfig;
|
|
86
|
+
/** Merge a partial configuration in. Pass `null` for a field to clear it and inherit again. */
|
|
87
|
+
set(patch: Partial<Record<keyof ScopeConfig, string | null | undefined>>): void;
|
|
88
|
+
/** Resolve the effective scope for a descendant (defaults to this scope's own element). */
|
|
89
|
+
resolve(el?: Element): ResolvedScope;
|
|
90
|
+
/** Send this scope's state to `frame` and keep it in sync. Returns a detach function. */
|
|
91
|
+
attachFrame(frame: HTMLIFrameElement): () => void;
|
|
92
|
+
/** Observe changes. Returns an unsubscribe function. */
|
|
93
|
+
subscribe(listener: (config: ScopeConfig) => void): () => void;
|
|
94
|
+
/** Remove the attributes, detach frames, and unregister. */
|
|
95
|
+
destroy(): void;
|
|
96
|
+
}
|
|
97
|
+
/** The scope registered for `instanceId` in `doc`, if any. */
|
|
98
|
+
export declare function getScope(instanceId?: string, doc?: Document): PantokenScope | undefined;
|
|
99
|
+
/**
|
|
100
|
+
* Create a scope rooted at `element`.
|
|
101
|
+
*
|
|
102
|
+
* @example Two independent themes on one page
|
|
103
|
+
* ```ts
|
|
104
|
+
* import { createScope } from "@pantoken/scope";
|
|
105
|
+
*
|
|
106
|
+
* createScope(document.querySelector("#app")!, { theme: "rebrand", scheme: "dark" });
|
|
107
|
+
* createScope(document.querySelector("#preview")!, {
|
|
108
|
+
* instanceId: "preview",
|
|
109
|
+
* theme: "canvas",
|
|
110
|
+
* scheme: "light",
|
|
111
|
+
* boundary: true,
|
|
112
|
+
* });
|
|
113
|
+
* ```
|
|
114
|
+
*/
|
|
115
|
+
export declare function createScope(element: HTMLElement, options?: CreateScopeOptions): PantokenScope;
|
|
116
|
+
/** Options for {@link connectScope}. */
|
|
117
|
+
interface ConnectScopeOptions {
|
|
118
|
+
/** The instance to accept messages for (default {@link DEFAULT_INSTANCE}). */
|
|
119
|
+
instanceId?: string;
|
|
120
|
+
/** The element to apply received state to (default `document.documentElement`). */
|
|
121
|
+
element?: HTMLElement;
|
|
122
|
+
/** The window to talk to (default `window.parent`). */
|
|
123
|
+
host?: Window | null;
|
|
124
|
+
/** The origin the host must have. Pass `"*"` only for an opaque-origin `srcdoc` document. */
|
|
125
|
+
hostOrigin?: string;
|
|
126
|
+
/** Called after each applied update. */
|
|
127
|
+
onChange?: (config: ScopeConfig) => void;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Run inside a frame: ask the host page's scope for its state and follow it.
|
|
131
|
+
*
|
|
132
|
+
* The frame gets its own scope element, so it can be told to render a different theme or scheme than
|
|
133
|
+
* the host — it is not inheriting ambiently.
|
|
134
|
+
*
|
|
135
|
+
* @returns A disconnect function.
|
|
136
|
+
*/
|
|
137
|
+
export declare function connectScope(options?: ConnectScopeOptions): () => void;
|
|
138
|
+
//#endregion
|
|
139
|
+
//#region src/inject.d.ts
|
|
140
|
+
/**
|
|
141
|
+
* Inject `css` into `doc` once. A second call with the same `id` does nothing, whichever instance
|
|
142
|
+
* makes it.
|
|
143
|
+
*
|
|
144
|
+
* @param css - The registrations sheet, e.g. `@pantoken/css/properties.css`.
|
|
145
|
+
* @param options - `id` distinguishes unrelated sheets; `doc` defaults to the current document.
|
|
146
|
+
* @returns `true` when this call performed the injection.
|
|
147
|
+
*
|
|
148
|
+
* @example
|
|
149
|
+
* ```ts
|
|
150
|
+
* import { ensureProperties } from "@pantoken/scope";
|
|
151
|
+
* import properties from "@pantoken/css/properties.css?inline";
|
|
152
|
+
*
|
|
153
|
+
* ensureProperties(properties);
|
|
154
|
+
* ```
|
|
155
|
+
*/
|
|
156
|
+
export declare function ensureProperties(css: string, options?: {
|
|
157
|
+
id?: string;
|
|
158
|
+
doc?: Document;
|
|
159
|
+
}): boolean;
|
|
160
|
+
/** True when a registrations sheet with `id` is already present in `doc`. */
|
|
161
|
+
export declare function hasProperties(id?: string, doc?: Document): boolean;
|
|
162
|
+
//#endregion
|
|
163
|
+
//#region src/observe.d.ts
|
|
164
|
+
/**
|
|
165
|
+
* Call `listener` whenever the scope resolved for `target` changes, including when an ancestor's
|
|
166
|
+
* attributes change.
|
|
167
|
+
*
|
|
168
|
+
* @returns A stop function.
|
|
169
|
+
*
|
|
170
|
+
* @example
|
|
171
|
+
* ```ts
|
|
172
|
+
* import { observeScope } from "@pantoken/scope";
|
|
173
|
+
*
|
|
174
|
+
* const stop = observeScope(el, (scope) => render(scope.theme, scope.scheme));
|
|
175
|
+
* ```
|
|
176
|
+
*/
|
|
177
|
+
export declare function observeScope(target: Element, listener: (scope: ResolvedScope) => void): () => void;
|
|
178
|
+
//#endregion
|
|
179
|
+
export { BOUNDARY_ATTR, BOUNDARY_CLASS, COLOR_ATTR, type ConnectScopeOptions, type CreateScopeOptions, INSTANCE_ATTR, type PantokenScope, type ResolvedScope, SCHEME_ATTR, SCOPE_ATTRS, type Scheme, type ScopeConfig, type ScopeMessage, THEME_ATTR, colorClass, isScheme, schemeClass, themeClass };
|
package/dist/scope.mjs
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { _ as THEME_ATTR, a as createScope, b as schemeClass, c as resolveScheme, d as BOUNDARY_CLASS, f as COLOR_ATTR, g as SCOPE_ATTRS, h as SCHEME_ATTR, i as connectScope, l as resolveScope, m as INSTANCE_ATTR, n as ensureProperties, o as getScope, p as DEFAULT_INSTANCE, r as hasProperties, s as resolveInstance, t as observeScope, u as BOUNDARY_ATTR, v as colorClass, x as themeClass, y as isScheme } from "./src-Daa7KXHA.mjs";
|
|
2
|
+
export { BOUNDARY_ATTR, BOUNDARY_CLASS, COLOR_ATTR, DEFAULT_INSTANCE, INSTANCE_ATTR, SCHEME_ATTR, SCOPE_ATTRS, THEME_ATTR, colorClass, connectScope, createScope, ensureProperties, getScope, hasProperties, isScheme, observeScope, resolveInstance, resolveScheme, resolveScope, schemeClass, themeClass };
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import { BOUNDARY_ATTR, BOUNDARY_CLASS, COLOR_ATTR, INSTANCE_ATTR, SCHEME_ATTR, SCOPE_ATTRS, THEME_ATTR, colorClass, isScheme, schemeClass, themeClass } from "@pantoken/utils/scope";
|
|
2
|
+
//#region src/contract.ts
|
|
3
|
+
/** The default instance id, used when a scope does not name one. */
|
|
4
|
+
const DEFAULT_INSTANCE = "pantoken";
|
|
5
|
+
//#endregion
|
|
6
|
+
//#region src/resolve.ts
|
|
7
|
+
/**
|
|
8
|
+
* Ancestor resolution — the "auto-detect inheritance" half of multi-instance support.
|
|
9
|
+
*
|
|
10
|
+
* Each scope attribute resolves independently: a subtree can override the color scheme while still
|
|
11
|
+
* inheriting the theme from an ancestor, which is what the CSS does too (separate cascade layers,
|
|
12
|
+
* separate attributes). Resolution stops at the first `data-pantoken-boundary` element, and never
|
|
13
|
+
* crosses a shadow root — a shadow root is an implicit boundary.
|
|
14
|
+
*
|
|
15
|
+
* @module
|
|
16
|
+
*/
|
|
17
|
+
function attr(el, name) {
|
|
18
|
+
const value = el.getAttribute(name);
|
|
19
|
+
return value === null || value === "" ? void 0 : value;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The value a `.--pantoken-<kind>-<value>` class encodes, if the element carries one.
|
|
23
|
+
*
|
|
24
|
+
* The emitter pairs every scope attribute with a class twin for hosts that strip `data-*`, so
|
|
25
|
+
* resolution has to read both or the runtime and the stylesheet disagree about what's in effect.
|
|
26
|
+
*/
|
|
27
|
+
function fromClass(el, kind) {
|
|
28
|
+
const prefix = `--pantoken-${kind}-`;
|
|
29
|
+
for (const cls of el.classList) if (cls.startsWith(prefix) && cls.length > prefix.length) return cls.slice(prefix.length);
|
|
30
|
+
}
|
|
31
|
+
const readTheme = (el) => attr(el, THEME_ATTR) ?? fromClass(el, "theme");
|
|
32
|
+
const readColor = (el) => attr(el, COLOR_ATTR) ?? fromClass(el, "color");
|
|
33
|
+
function readScheme(el) {
|
|
34
|
+
const value = attr(el, SCHEME_ATTR) ?? fromClass(el, "scheme");
|
|
35
|
+
return isScheme(value) ? value : void 0;
|
|
36
|
+
}
|
|
37
|
+
const isBoundary = (el) => el.hasAttribute(BOUNDARY_ATTR) || el.classList.contains(BOUNDARY_CLASS);
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the scope in effect for `start` by walking up its ancestors.
|
|
40
|
+
*
|
|
41
|
+
* @param start - The element to resolve for. Its own attributes participate.
|
|
42
|
+
* @returns The {@link ResolvedScope}; fields no ancestor declared are left `undefined`.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```ts
|
|
46
|
+
* import { resolveScope } from "@pantoken/scope";
|
|
47
|
+
*
|
|
48
|
+
* // <div data-pantoken-theme="canvas"><span id="x"></span></div>
|
|
49
|
+
* resolveScope(document.querySelector("#x")!).theme; // "canvas"
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
function resolveScope(start) {
|
|
53
|
+
let theme;
|
|
54
|
+
let scheme;
|
|
55
|
+
let color;
|
|
56
|
+
let instanceId;
|
|
57
|
+
let element = null;
|
|
58
|
+
let bounded = false;
|
|
59
|
+
for (let el = start; el; el = el.parentElement) {
|
|
60
|
+
let declared = false;
|
|
61
|
+
const nextTheme = readTheme(el);
|
|
62
|
+
if (theme === void 0 && nextTheme !== void 0) {
|
|
63
|
+
theme = nextTheme;
|
|
64
|
+
declared = true;
|
|
65
|
+
}
|
|
66
|
+
const nextScheme = readScheme(el);
|
|
67
|
+
if (scheme === void 0 && nextScheme !== void 0) {
|
|
68
|
+
scheme = nextScheme;
|
|
69
|
+
declared = true;
|
|
70
|
+
}
|
|
71
|
+
const nextColor = readColor(el);
|
|
72
|
+
if (color === void 0 && nextColor !== void 0) {
|
|
73
|
+
color = nextColor;
|
|
74
|
+
declared = true;
|
|
75
|
+
}
|
|
76
|
+
const nextInstance = attr(el, INSTANCE_ATTR);
|
|
77
|
+
if (instanceId === void 0 && nextInstance !== void 0) {
|
|
78
|
+
instanceId = nextInstance;
|
|
79
|
+
declared = true;
|
|
80
|
+
}
|
|
81
|
+
if (declared && element === null) element = el;
|
|
82
|
+
if (isBoundary(el)) {
|
|
83
|
+
bounded = true;
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
theme,
|
|
89
|
+
scheme,
|
|
90
|
+
color,
|
|
91
|
+
instanceId,
|
|
92
|
+
element,
|
|
93
|
+
bounded
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The instance that owns `start`, falling back to {@link DEFAULT_INSTANCE}.
|
|
98
|
+
*
|
|
99
|
+
* Use this to decide whether an event or message is yours before acting on it.
|
|
100
|
+
*/
|
|
101
|
+
function resolveInstance(start) {
|
|
102
|
+
return resolveScope(start).instanceId ?? "pantoken";
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The effective color scheme for `start`: an explicit pin if any ancestor set one, otherwise the
|
|
106
|
+
* `color-scheme` the element actually computes to, otherwise the OS preference.
|
|
107
|
+
*
|
|
108
|
+
* Never reads `document.documentElement` — that global read is exactly what desynchronises two
|
|
109
|
+
* instances on one page.
|
|
110
|
+
*/
|
|
111
|
+
function resolveScheme(start) {
|
|
112
|
+
const pinned = resolveScope(start).scheme;
|
|
113
|
+
if (pinned) return pinned;
|
|
114
|
+
const view = start?.ownerDocument?.defaultView;
|
|
115
|
+
if (start && view) {
|
|
116
|
+
const computed = view.getComputedStyle(start).colorScheme;
|
|
117
|
+
if (computed === "dark") return "dark";
|
|
118
|
+
if (computed === "light") return "light";
|
|
119
|
+
}
|
|
120
|
+
return view?.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
121
|
+
}
|
|
122
|
+
//#endregion
|
|
123
|
+
//#region src/scope.ts
|
|
124
|
+
/**
|
|
125
|
+
* Scope instances — the "manual config to avoid collision" half of multi-instance support.
|
|
126
|
+
*
|
|
127
|
+
* A scope owns one element and writes the `data-pantoken-*` attributes onto it. Everything below
|
|
128
|
+
* that element resolves to it; siblings are independent. State is persisted under a key namespaced
|
|
129
|
+
* by instance id, and child frames are messaged individually after opting in via
|
|
130
|
+
* {@link PantokenScope.attachFrame} — never by sweeping the whole document for iframes.
|
|
131
|
+
*
|
|
132
|
+
* @module
|
|
133
|
+
*/
|
|
134
|
+
const registries = /* @__PURE__ */ new WeakMap();
|
|
135
|
+
function registryFor(doc) {
|
|
136
|
+
let registry = registries.get(doc);
|
|
137
|
+
if (!registry) {
|
|
138
|
+
registry = /* @__PURE__ */ new Map();
|
|
139
|
+
registries.set(doc, registry);
|
|
140
|
+
}
|
|
141
|
+
return registry;
|
|
142
|
+
}
|
|
143
|
+
/** The scope registered for `instanceId` in `doc`, if any. */
|
|
144
|
+
function getScope(instanceId = DEFAULT_INSTANCE, doc = document) {
|
|
145
|
+
return registryFor(doc).get(instanceId);
|
|
146
|
+
}
|
|
147
|
+
const storageKey = (instanceId, field) => `pantoken:${instanceId}:${field}`;
|
|
148
|
+
function defaultStorage() {
|
|
149
|
+
try {
|
|
150
|
+
return typeof localStorage === "undefined" ? null : localStorage;
|
|
151
|
+
} catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* The origin to post to for `frame`. A `srcdoc` frame has an opaque origin that can only be
|
|
157
|
+
* addressed as `"*"`; anything with a real `src` gets its concrete origin so the message cannot
|
|
158
|
+
* leak to a navigated-away document.
|
|
159
|
+
*/
|
|
160
|
+
function frameOrigin(frame) {
|
|
161
|
+
if (!frame.hasAttribute("src")) return "*";
|
|
162
|
+
try {
|
|
163
|
+
return new URL(frame.src, frame.ownerDocument.location.href).origin;
|
|
164
|
+
} catch {
|
|
165
|
+
return frame.ownerDocument.location.origin;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const FIELDS = {
|
|
169
|
+
theme: THEME_ATTR,
|
|
170
|
+
scheme: SCHEME_ATTR,
|
|
171
|
+
color: COLOR_ATTR
|
|
172
|
+
};
|
|
173
|
+
/**
|
|
174
|
+
* Create a scope rooted at `element`.
|
|
175
|
+
*
|
|
176
|
+
* @example Two independent themes on one page
|
|
177
|
+
* ```ts
|
|
178
|
+
* import { createScope } from "@pantoken/scope";
|
|
179
|
+
*
|
|
180
|
+
* createScope(document.querySelector("#app")!, { theme: "rebrand", scheme: "dark" });
|
|
181
|
+
* createScope(document.querySelector("#preview")!, {
|
|
182
|
+
* instanceId: "preview",
|
|
183
|
+
* theme: "canvas",
|
|
184
|
+
* scheme: "light",
|
|
185
|
+
* boundary: true,
|
|
186
|
+
* });
|
|
187
|
+
* ```
|
|
188
|
+
*/
|
|
189
|
+
function createScope(element, options = {}) {
|
|
190
|
+
const { instanceId = DEFAULT_INSTANCE, boundary = false, persist = true, storage = defaultStorage(), ...initial } = options;
|
|
191
|
+
const doc = element.ownerDocument;
|
|
192
|
+
const registry = registryFor(doc);
|
|
193
|
+
registry.get(instanceId)?.destroy();
|
|
194
|
+
const read = (field) => {
|
|
195
|
+
if (!persist || !storage) return void 0;
|
|
196
|
+
try {
|
|
197
|
+
return storage.getItem(storageKey(instanceId, field)) ?? void 0;
|
|
198
|
+
} catch {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
const config = {
|
|
203
|
+
theme: initial.theme ?? read("theme"),
|
|
204
|
+
scheme: initial.scheme ?? (isScheme(read("scheme")) ? read("scheme") : void 0),
|
|
205
|
+
color: initial.color ?? read("color"),
|
|
206
|
+
instanceId
|
|
207
|
+
};
|
|
208
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
209
|
+
const frames = /* @__PURE__ */ new Set();
|
|
210
|
+
let destroyed = false;
|
|
211
|
+
const write = () => {
|
|
212
|
+
element.setAttribute(INSTANCE_ATTR, instanceId);
|
|
213
|
+
if (boundary) element.setAttribute(BOUNDARY_ATTR, "");
|
|
214
|
+
for (const [field, attribute] of Object.entries(FIELDS)) {
|
|
215
|
+
const value = config[field];
|
|
216
|
+
if (value === void 0) element.removeAttribute(attribute);
|
|
217
|
+
else element.setAttribute(attribute, value);
|
|
218
|
+
}
|
|
219
|
+
element.style.colorScheme = config.scheme ?? "";
|
|
220
|
+
};
|
|
221
|
+
const persistConfig = () => {
|
|
222
|
+
if (!persist || !storage) return;
|
|
223
|
+
for (const field of Object.keys(FIELDS)) try {
|
|
224
|
+
storage.setItem(storageKey(instanceId, field), config[field] ?? "");
|
|
225
|
+
} catch {}
|
|
226
|
+
};
|
|
227
|
+
const post = (frame) => {
|
|
228
|
+
const message = {
|
|
229
|
+
type: "pantoken-scope",
|
|
230
|
+
instanceId,
|
|
231
|
+
...config
|
|
232
|
+
};
|
|
233
|
+
frame.contentWindow?.postMessage(message, frameOrigin(frame));
|
|
234
|
+
};
|
|
235
|
+
const broadcast = () => {
|
|
236
|
+
for (const frame of frames) post(frame);
|
|
237
|
+
for (const listener of listeners) listener({ ...config });
|
|
238
|
+
};
|
|
239
|
+
const onFrameRequest = (event) => {
|
|
240
|
+
const data = event.data;
|
|
241
|
+
if (data?.type !== "pantoken-scope-request") return;
|
|
242
|
+
if (data.instanceId !== instanceId) return;
|
|
243
|
+
for (const frame of frames) if (frame.contentWindow === event.source) post(frame);
|
|
244
|
+
};
|
|
245
|
+
doc.defaultView?.addEventListener("message", onFrameRequest);
|
|
246
|
+
write();
|
|
247
|
+
persistConfig();
|
|
248
|
+
const scope = {
|
|
249
|
+
element,
|
|
250
|
+
instanceId,
|
|
251
|
+
get: () => ({ ...config }),
|
|
252
|
+
set(patch) {
|
|
253
|
+
if (destroyed) return;
|
|
254
|
+
for (const field of Object.keys(FIELDS)) {
|
|
255
|
+
if (!(field in patch)) continue;
|
|
256
|
+
const value = patch[field];
|
|
257
|
+
if (value === null || value === void 0) delete config[field];
|
|
258
|
+
else if (field === "scheme") config.scheme = isScheme(value) ? value : void 0;
|
|
259
|
+
else config[field] = value;
|
|
260
|
+
}
|
|
261
|
+
write();
|
|
262
|
+
persistConfig();
|
|
263
|
+
broadcast();
|
|
264
|
+
},
|
|
265
|
+
resolve: (el = element) => resolveScope(el),
|
|
266
|
+
attachFrame(frame) {
|
|
267
|
+
frames.add(frame);
|
|
268
|
+
post(frame);
|
|
269
|
+
return () => {
|
|
270
|
+
frames.delete(frame);
|
|
271
|
+
};
|
|
272
|
+
},
|
|
273
|
+
subscribe(listener) {
|
|
274
|
+
listeners.add(listener);
|
|
275
|
+
return () => {
|
|
276
|
+
listeners.delete(listener);
|
|
277
|
+
};
|
|
278
|
+
},
|
|
279
|
+
destroy() {
|
|
280
|
+
if (destroyed) return;
|
|
281
|
+
destroyed = true;
|
|
282
|
+
doc.defaultView?.removeEventListener("message", onFrameRequest);
|
|
283
|
+
frames.clear();
|
|
284
|
+
listeners.clear();
|
|
285
|
+
for (const attribute of [
|
|
286
|
+
...Object.values(FIELDS),
|
|
287
|
+
INSTANCE_ATTR,
|
|
288
|
+
BOUNDARY_ATTR
|
|
289
|
+
]) element.removeAttribute(attribute);
|
|
290
|
+
element.style.colorScheme = "";
|
|
291
|
+
if (registry.get(instanceId) === scope) registry.delete(instanceId);
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
registry.set(instanceId, scope);
|
|
295
|
+
return scope;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Run inside a frame: ask the host page's scope for its state and follow it.
|
|
299
|
+
*
|
|
300
|
+
* The frame gets its own scope element, so it can be told to render a different theme or scheme than
|
|
301
|
+
* the host — it is not inheriting ambiently.
|
|
302
|
+
*
|
|
303
|
+
* @returns A disconnect function.
|
|
304
|
+
*/
|
|
305
|
+
function connectScope(options = {}) {
|
|
306
|
+
const { instanceId = DEFAULT_INSTANCE, element = document.documentElement, host = window.parent, hostOrigin, onChange } = options;
|
|
307
|
+
const local = createScope(element, {
|
|
308
|
+
instanceId,
|
|
309
|
+
persist: false
|
|
310
|
+
});
|
|
311
|
+
const onMessage = (event) => {
|
|
312
|
+
if (host && event.source !== host) return;
|
|
313
|
+
if (hostOrigin && hostOrigin !== "*" && event.origin !== hostOrigin) return;
|
|
314
|
+
const data = event.data;
|
|
315
|
+
if (data?.type !== "pantoken-scope" || data.instanceId !== instanceId) return;
|
|
316
|
+
local.set({
|
|
317
|
+
theme: data.theme,
|
|
318
|
+
scheme: data.scheme,
|
|
319
|
+
color: data.color
|
|
320
|
+
});
|
|
321
|
+
onChange?.(local.get());
|
|
322
|
+
};
|
|
323
|
+
window.addEventListener("message", onMessage);
|
|
324
|
+
const request = {
|
|
325
|
+
type: "pantoken-scope-request",
|
|
326
|
+
instanceId
|
|
327
|
+
};
|
|
328
|
+
host?.postMessage(request, hostOrigin ?? "*");
|
|
329
|
+
return () => {
|
|
330
|
+
window.removeEventListener("message", onMessage);
|
|
331
|
+
local.destroy();
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
//#endregion
|
|
335
|
+
//#region src/inject.ts
|
|
336
|
+
/**
|
|
337
|
+
* One-time injection of the document-global half of the token sheet.
|
|
338
|
+
*
|
|
339
|
+
* `@property` registrations are document-scoped, so a second instance injecting its own copy would
|
|
340
|
+
* silently redefine every token's initial-value for the whole page. {@link ensureProperties} makes
|
|
341
|
+
* that a no-op instead.
|
|
342
|
+
*
|
|
343
|
+
* @module
|
|
344
|
+
*/
|
|
345
|
+
const MARKER = "data-pantoken-properties";
|
|
346
|
+
/**
|
|
347
|
+
* Inject `css` into `doc` once. A second call with the same `id` does nothing, whichever instance
|
|
348
|
+
* makes it.
|
|
349
|
+
*
|
|
350
|
+
* @param css - The registrations sheet, e.g. `@pantoken/css/properties.css`.
|
|
351
|
+
* @param options - `id` distinguishes unrelated sheets; `doc` defaults to the current document.
|
|
352
|
+
* @returns `true` when this call performed the injection.
|
|
353
|
+
*
|
|
354
|
+
* @example
|
|
355
|
+
* ```ts
|
|
356
|
+
* import { ensureProperties } from "@pantoken/scope";
|
|
357
|
+
* import properties from "@pantoken/css/properties.css?inline";
|
|
358
|
+
*
|
|
359
|
+
* ensureProperties(properties);
|
|
360
|
+
* ```
|
|
361
|
+
*/
|
|
362
|
+
function ensureProperties(css, options = {}) {
|
|
363
|
+
const { id = "default", doc = document } = options;
|
|
364
|
+
if (doc.head.querySelector(`style[${MARKER}="${CSS.escape(id)}"]`)) return false;
|
|
365
|
+
const style = doc.createElement("style");
|
|
366
|
+
style.setAttribute(MARKER, id);
|
|
367
|
+
style.textContent = css;
|
|
368
|
+
doc.head.prepend(style);
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
/** True when a registrations sheet with `id` is already present in `doc`. */
|
|
372
|
+
function hasProperties(id = "default", doc = document) {
|
|
373
|
+
return doc.head.querySelector(`style[${MARKER}="${CSS.escape(id)}"]`) !== null;
|
|
374
|
+
}
|
|
375
|
+
//#endregion
|
|
376
|
+
//#region src/observe.ts
|
|
377
|
+
/**
|
|
378
|
+
* Reacting to scope changes made outside this instance — an ancestor retheming, or another
|
|
379
|
+
* instance's scope element changing above ours.
|
|
380
|
+
*
|
|
381
|
+
* @module
|
|
382
|
+
*/
|
|
383
|
+
/**
|
|
384
|
+
* Call `listener` whenever the scope resolved for `target` changes, including when an ancestor's
|
|
385
|
+
* attributes change.
|
|
386
|
+
*
|
|
387
|
+
* @returns A stop function.
|
|
388
|
+
*
|
|
389
|
+
* @example
|
|
390
|
+
* ```ts
|
|
391
|
+
* import { observeScope } from "@pantoken/scope";
|
|
392
|
+
*
|
|
393
|
+
* const stop = observeScope(el, (scope) => render(scope.theme, scope.scheme));
|
|
394
|
+
* ```
|
|
395
|
+
*/
|
|
396
|
+
function observeScope(target, listener) {
|
|
397
|
+
const view = target.ownerDocument.defaultView;
|
|
398
|
+
if (!view?.MutationObserver) return () => {};
|
|
399
|
+
let previous = resolveScope(target);
|
|
400
|
+
const observer = new view.MutationObserver(() => {
|
|
401
|
+
const next = resolveScope(target);
|
|
402
|
+
if (next.theme === previous.theme && next.scheme === previous.scheme && next.color === previous.color && next.instanceId === previous.instanceId) return;
|
|
403
|
+
previous = next;
|
|
404
|
+
listener(next);
|
|
405
|
+
});
|
|
406
|
+
observer.observe(target.ownerDocument.documentElement, {
|
|
407
|
+
attributes: true,
|
|
408
|
+
attributeFilter: [...SCOPE_ATTRS, "class"],
|
|
409
|
+
subtree: true
|
|
410
|
+
});
|
|
411
|
+
return () => {
|
|
412
|
+
observer.disconnect();
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
//#endregion
|
|
416
|
+
export { THEME_ATTR as _, createScope as a, schemeClass as b, resolveScheme as c, BOUNDARY_CLASS as d, COLOR_ATTR as f, SCOPE_ATTRS as g, SCHEME_ATTR as h, connectScope as i, resolveScope as l, INSTANCE_ATTR as m, ensureProperties as n, getScope as o, DEFAULT_INSTANCE as p, hasProperties as r, resolveInstance as s, observeScope as t, BOUNDARY_ATTR as u, colorClass as v, themeClass as x, isScheme as y };
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pantoken/scope",
|
|
3
|
+
"version": "0.3.11",
|
|
4
|
+
"description": "Run several pantoken themes and color schemes in one document: per-subtree scopes, ancestor resolution, and hard boundaries.",
|
|
5
|
+
"homepage": "https://pantoken.app",
|
|
6
|
+
"bugs": "https://github.com/thedannywahl/pantoken/issues",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/thedannywahl/pantoken.git",
|
|
11
|
+
"directory": "formats/scope"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
16
|
+
"type": "module",
|
|
17
|
+
"sideEffects": false,
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/scope.d.mts",
|
|
21
|
+
"default": "./dist/index.mjs"
|
|
22
|
+
},
|
|
23
|
+
"./package.json": "./package.json"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public",
|
|
27
|
+
"provenance": true
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@pantoken/utils": "1.0.3"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "^26.6.1",
|
|
34
|
+
"happy-dom": "^20.14.5",
|
|
35
|
+
"typescript": "^7.0.2",
|
|
36
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@0.3.3",
|
|
37
|
+
"vite-plus": "0.3.3"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=22.18.0"
|
|
41
|
+
},
|
|
42
|
+
"pantoken": {
|
|
43
|
+
"key": "scope",
|
|
44
|
+
"kind": "namespace"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"dev": "vp pack --watch",
|
|
48
|
+
"test": "vp test",
|
|
49
|
+
"check": "vp check"
|
|
50
|
+
}
|
|
51
|
+
}
|