@marianmeres/widget-provider 1.0.2

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/AGENTS.md ADDED
@@ -0,0 +1,45 @@
1
+ # @marianmeres/widget-provider — Agent Guide
2
+
3
+ ## Quick Reference
4
+ - **Stack**: Deno, TypeScript, browser DOM APIs
5
+ - **Runtime**: Deno (primary), npm (secondary via build)
6
+ - **Dependency**: `@marianmeres/store` (reactive state)
7
+ - **Test**: `deno test` | **Build**: `deno task npm:build` | **Publish**: `deno task publish`
8
+
9
+ ## Project Structure
10
+ ```
11
+ /src
12
+ mod.ts — Public entry point (re-exports)
13
+ types.ts — All type definitions and constants
14
+ style-presets.ts — CSS preset configs and apply functions
15
+ widget-provider.ts — Core provideWidget() implementation
16
+ /tests — Deno tests (unit tests for pure functions)
17
+ /scripts — npm build script
18
+ /example — Dev example app
19
+ ```
20
+
21
+ ## What This Library Does
22
+ Embeds an iframe-based widget into a host page with:
23
+ - Style presets (float, fullscreen, inline) for positioning
24
+ - postMessage-based bidirectional communication (namespaced with `@@__widget_provider__@@`)
25
+ - Show/hide animations (fade-scale, slide-up)
26
+ - Optional trigger button (auto-toggles with widget visibility)
27
+ - Reactive state via `@marianmeres/store` (Svelte-compatible subscribe)
28
+
29
+ ## Critical Conventions
30
+ 1. All message types are prefixed with `MSG_PREFIX` (`@@__widget_provider__@@`)
31
+ 2. Types live in `types.ts`, style logic in `style-presets.ts`, core logic in `widget-provider.ts`
32
+ 3. `mod.ts` is the sole public entry point — all public exports go through it
33
+ 4. Use Deno formatting: tabs, 90 char line width (`deno fmt`)
34
+ 5. `provideWidget()` is the only user-facing factory — returns `WidgetProviderApi`
35
+
36
+ ## Before Making Changes
37
+ - [ ] Check existing patterns in similar files
38
+ - [ ] Run `deno test`
39
+ - [ ] Run `deno fmt`
40
+ - [ ] Ensure all public exports are re-exported from `mod.ts`
41
+
42
+ ## Documentation Index
43
+ - [Architecture](./docs/architecture.md)
44
+ - [Conventions](./docs/conventions.md)
45
+ - [Tasks](./docs/tasks.md)
package/API.md ADDED
@@ -0,0 +1,204 @@
1
+ # API
2
+
3
+ ## Functions
4
+
5
+ ### `provideWidget(options)`
6
+
7
+ Create and embed an iframe-based widget. Returns a control API object.
8
+
9
+ **Parameters:**
10
+ - `options` (`WidgetProviderOptions`) — Configuration object (see below)
11
+
12
+ **Returns:** `WidgetProviderApi`
13
+
14
+ **Example:**
15
+ ```typescript
16
+ import { provideWidget } from '@marianmeres/widget-provider';
17
+
18
+ const widget = provideWidget({
19
+ widgetUrl: 'https://example.com/widget',
20
+ stylePreset: 'float',
21
+ animate: 'slide-up',
22
+ trigger: { content: '<span>Chat</span>' },
23
+ });
24
+ ```
25
+
26
+ ---
27
+
28
+ ### `resolveAllowedOrigins(explicit, widgetUrl)`
29
+
30
+ Resolve the list of allowed origins for postMessage validation.
31
+
32
+ **Parameters:**
33
+ - `explicit` (`string | string[] | undefined`) — Explicitly configured origin(s)
34
+ - `widgetUrl` (`string`) — The widget URL to derive origin from
35
+
36
+ **Returns:** `string[]` — Array of allowed origin strings
37
+
38
+ **Example:**
39
+ ```typescript
40
+ resolveAllowedOrigins(undefined, 'https://example.com/app');
41
+ // => ['https://example.com']
42
+
43
+ resolveAllowedOrigins(['https://a.com', 'https://b.com'], 'https://c.com/app');
44
+ // => ['https://a.com', 'https://b.com']
45
+ ```
46
+
47
+ ---
48
+
49
+ ### `isOriginAllowed(origin, allowed)`
50
+
51
+ Check whether a given origin is in the allowed list.
52
+
53
+ **Parameters:**
54
+ - `origin` (`string`) — Origin to check
55
+ - `allowed` (`string[]`) — List of allowed origins (use `"*"` to allow any)
56
+
57
+ **Returns:** `boolean`
58
+
59
+ ---
60
+
61
+ ### `resolveAnimateConfig(opt)`
62
+
63
+ Resolve animation option into a concrete `AnimateConfig` or `null`.
64
+
65
+ **Parameters:**
66
+ - `opt` (`boolean | AnimatePreset | { preset?: AnimatePreset; transition?: string } | undefined`)
67
+
68
+ **Returns:** `AnimateConfig | null`
69
+
70
+ ---
71
+
72
+ ## Types
73
+
74
+ ### `WidgetProviderOptions`
75
+
76
+ ```typescript
77
+ interface WidgetProviderOptions {
78
+ /** The URL of the SPA to embed (required) */
79
+ widgetUrl: string;
80
+ /** DOM element to append the widget into. Default: document.body */
81
+ parentContainer?: HTMLElement;
82
+ /** Positioning mode. Default: "inline" */
83
+ stylePreset?: StylePreset;
84
+ /** CSS overrides applied to the container wrapper div */
85
+ styleOverrides?: StyleOverrides;
86
+ /** Allowed origin(s) for postMessage validation. Derived from widgetUrl if omitted */
87
+ allowedOrigin?: string | string[];
88
+ /** Whether the widget starts visible. Default: true */
89
+ visible?: boolean;
90
+ /** Iframe sandbox attribute. Default: "allow-scripts allow-same-origin" */
91
+ sandbox?: string;
92
+ /** Additional iframe attributes (e.g. allow, referrerpolicy) */
93
+ iframeAttrs?: Record<string, string>;
94
+ /** Opt-in show/hide animation: true | AnimatePreset | { preset?, transition? } */
95
+ animate?: boolean | AnimatePreset | { preset?: AnimatePreset; transition?: string };
96
+ /** Built-in floating trigger button: true | { content?, style? } */
97
+ trigger?: boolean | { content?: string; style?: Partial<CSSStyleDeclaration> };
98
+ }
99
+ ```
100
+
101
+ ---
102
+
103
+ ### `WidgetProviderApi`
104
+
105
+ The object returned by `provideWidget()`.
106
+
107
+ | Method / Property | Signature | Description |
108
+ |-------------------|-----------|-------------|
109
+ | `show()` | `() => void` | Show the widget container |
110
+ | `hide()` | `() => void` | Hide the widget container |
111
+ | `toggle()` | `() => void` | Toggle visibility |
112
+ | `destroy()` | `() => void` | Remove iframe, listeners, DOM elements. Irreversible |
113
+ | `setPreset(preset)` | `(preset: StylePreset) => void` | Switch style preset at runtime |
114
+ | `maximize()` | `() => void` | Switch to fullscreen preset |
115
+ | `minimize()` | `() => void` | Switch back to initial preset |
116
+ | `requestNativeFullscreen()` | `() => Promise<void>` | Browser fullscreen for iframe |
117
+ | `exitNativeFullscreen()` | `() => Promise<void>` | Exit browser fullscreen |
118
+ | `send(type, payload?)` | `<T>(type: string, payload?: T) => void` | Send message to iframe |
119
+ | `onMessage(type, handler)` | `<T>(type: string, handler: (payload: T) => void) => Unsubscribe` | Listen for iframe messages |
120
+ | `subscribe(cb)` | `(cb: (state: WidgetState) => void) => Unsubscribe` | Reactive state subscription |
121
+ | `get()` | `() => WidgetState` | Get current state snapshot |
122
+ | `iframe` | `readonly HTMLIFrameElement` | Direct iframe element reference |
123
+ | `container` | `readonly HTMLElement` | Direct container div reference |
124
+ | `trigger` | `readonly HTMLElement \| null` | Trigger button reference, or null |
125
+
126
+ ---
127
+
128
+ ### `WidgetState`
129
+
130
+ ```typescript
131
+ interface WidgetState {
132
+ visible: boolean;
133
+ ready: boolean;
134
+ destroyed: boolean;
135
+ preset: StylePreset;
136
+ }
137
+ ```
138
+
139
+ ---
140
+
141
+ ### `WidgetMessage<T>`
142
+
143
+ ```typescript
144
+ interface WidgetMessage<T = unknown> {
145
+ type: string;
146
+ payload?: T;
147
+ }
148
+ ```
149
+
150
+ ---
151
+
152
+ ### `StylePreset`
153
+
154
+ ```typescript
155
+ type StylePreset = "float" | "fullscreen" | "inline";
156
+ ```
157
+
158
+ ---
159
+
160
+ ### `AnimatePreset`
161
+
162
+ ```typescript
163
+ type AnimatePreset = "fade-scale" | "slide-up";
164
+ ```
165
+
166
+ ---
167
+
168
+ ### `StyleOverrides`
169
+
170
+ ```typescript
171
+ type StyleOverrides = Partial<CSSStyleDeclaration>;
172
+ ```
173
+
174
+ ---
175
+
176
+ ### `AnimateConfig`
177
+
178
+ ```typescript
179
+ interface AnimateConfig {
180
+ transition: string;
181
+ hidden: Partial<CSSStyleDeclaration>;
182
+ visible: Partial<CSSStyleDeclaration>;
183
+ }
184
+ ```
185
+
186
+ ---
187
+
188
+ ## Constants
189
+
190
+ ### `MSG_PREFIX`
191
+
192
+ `"@@__widget_provider__@@"` — Namespace prefix for all postMessage types.
193
+
194
+ ### `STYLE_PRESETS`
195
+
196
+ `Record<StylePreset, Partial<CSSStyleDeclaration>>` — CSS property objects for each positioning mode.
197
+
198
+ ### `ANIMATE_PRESETS`
199
+
200
+ `Record<AnimatePreset, AnimateConfig>` — Animation configurations for show/hide transitions.
201
+
202
+ ### `IFRAME_BASE`
203
+
204
+ `Partial<CSSStyleDeclaration>` — Base CSS applied to all iframes (100% width/height, no border).
package/CLAUDE.md ADDED
@@ -0,0 +1,3 @@
1
+ # Project Instructions
2
+
3
+ See [AGENTS.md](./AGENTS.md) for complete project documentation and AI agent instructions.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marian Meres
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 ADDED
@@ -0,0 +1,76 @@
1
+ # @marianmeres/widget-provider
2
+
3
+ [![NPM](https://img.shields.io/npm/v/@marianmeres/widget-provider)](https://www.npmjs.com/package/@marianmeres/widget-provider)
4
+ [![JSR](https://jsr.io/badges/@marianmeres/widget-provider)](https://jsr.io/@marianmeres/widget-provider)
5
+ [![License](https://img.shields.io/npm/l/@marianmeres/widget-provider)](LICENSE)
6
+
7
+ Embed an iframe-based widget into a host page with built-in positioning presets,
8
+ bidirectional postMessage communication, show/hide animations, and reactive state.
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ npm install @marianmeres/widget-provider
14
+ ```
15
+
16
+ Or via JSR:
17
+
18
+ ```bash
19
+ deno add jsr:@marianmeres/widget-provider
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ```typescript
25
+ import { provideWidget } from '@marianmeres/widget-provider';
26
+
27
+ const widget = provideWidget({
28
+ widgetUrl: 'https://example.com/my-widget',
29
+ stylePreset: 'float', // "float" | "fullscreen" | "inline"
30
+ animate: true, // fade-scale animation
31
+ trigger: true, // show floating trigger button when hidden
32
+ });
33
+
34
+ // Control visibility
35
+ widget.show();
36
+ widget.hide();
37
+ widget.toggle();
38
+
39
+ // Send messages to the iframe
40
+ widget.send('greet', { name: 'World' });
41
+
42
+ // Listen for messages from the iframe
43
+ const unsub = widget.onMessage('response', (payload) => {
44
+ console.log(payload);
45
+ });
46
+
47
+ // Subscribe to reactive state changes
48
+ widget.subscribe((state) => {
49
+ console.log(state.visible, state.ready);
50
+ });
51
+
52
+ // Clean up
53
+ widget.destroy();
54
+ ```
55
+
56
+ ### Style Presets
57
+
58
+ | Preset | Description |
59
+ |--------|-------------|
60
+ | `"inline"` | Flows within parent container (default) |
61
+ | `"float"` | Fixed bottom-right chat-widget style |
62
+ | `"fullscreen"` | Covers viewport with backdrop overlay |
63
+
64
+ ### Message Protocol
65
+
66
+ Messages between the host and iframe are namespaced with `@@__widget_provider__@@`
67
+ prefix. The iframe can send built-in control messages: `ready`, `maximize`, `minimize`,
68
+ `hide`, `close`, `setPreset`, `nativeFullscreen`, `exitNativeFullscreen`.
69
+
70
+ ## API
71
+
72
+ See [API.md](API.md) for complete API documentation.
73
+
74
+ ## License
75
+
76
+ [MIT](LICENSE)
package/dist/mod.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { provideWidget, resolveAllowedOrigins, isOriginAllowed, resolveAnimateConfig, } from "./widget-provider.js";
2
+ export type { WidgetProviderOptions, WidgetProviderApi, WidgetState, WidgetMessage, StylePreset, StyleOverrides, AnimatePreset, MessageHandler, Unsubscribe, } from "./types.js";
3
+ export { MSG_PREFIX } from "./types.js";
4
+ export { STYLE_PRESETS, IFRAME_BASE, ANIMATE_PRESETS, type AnimateConfig, } from "./style-presets.js";
package/dist/mod.js ADDED
@@ -0,0 +1,3 @@
1
+ export { provideWidget, resolveAllowedOrigins, isOriginAllowed, resolveAnimateConfig, } from "./widget-provider.js";
2
+ export { MSG_PREFIX } from "./types.js";
3
+ export { STYLE_PRESETS, IFRAME_BASE, ANIMATE_PRESETS, } from "./style-presets.js";
@@ -0,0 +1,18 @@
1
+ import type { AnimatePreset, StylePreset } from "./types.js";
2
+ type CSSProps = Partial<CSSStyleDeclaration>;
3
+ /** CSS transition and visibility states for a show/hide animation */
4
+ export interface AnimateConfig {
5
+ transition: string;
6
+ hidden: CSSProps;
7
+ visible: CSSProps;
8
+ }
9
+ /** Built-in animation configurations keyed by {@linkcode AnimatePreset} name */
10
+ export declare const ANIMATE_PRESETS: Record<AnimatePreset, AnimateConfig>;
11
+ /** Base CSS styles applied to every widget iframe (100% size, no border) */
12
+ export declare const IFRAME_BASE: CSSProps;
13
+ export declare const TRIGGER_BASE: CSSProps;
14
+ /** CSS property objects for each positioning mode keyed by {@linkcode StylePreset} name */
15
+ export declare const STYLE_PRESETS: Record<StylePreset, CSSProps>;
16
+ export declare function applyPreset(container: HTMLElement, preset: StylePreset, overrides: Partial<CSSStyleDeclaration>): void;
17
+ export declare function applyIframeBaseStyles(iframe: HTMLIFrameElement): void;
18
+ export {};
@@ -0,0 +1,86 @@
1
+ /** Built-in animation configurations keyed by {@linkcode AnimatePreset} name */
2
+ export const ANIMATE_PRESETS = {
3
+ "fade-scale": {
4
+ transition: "opacity 200ms ease, transform 200ms ease",
5
+ hidden: { opacity: "0", transform: "scale(0.9)" },
6
+ visible: { opacity: "1", transform: "scale(1)" },
7
+ },
8
+ "slide-up": {
9
+ transition: "opacity 200ms ease, transform 200ms ease",
10
+ hidden: { opacity: "0", transform: "translateY(20px)" },
11
+ visible: { opacity: "1", transform: "translateY(0)" },
12
+ },
13
+ };
14
+ const BASE_CONTAINER = {
15
+ boxSizing: "border-box",
16
+ overflow: "hidden",
17
+ };
18
+ /** Base CSS styles applied to every widget iframe (100% size, no border) */
19
+ export const IFRAME_BASE = {
20
+ width: "100%",
21
+ height: "100%",
22
+ border: "none",
23
+ display: "block",
24
+ };
25
+ const PRESET_FLOAT = {
26
+ ...BASE_CONTAINER,
27
+ position: "fixed",
28
+ bottom: "20px",
29
+ right: "20px",
30
+ width: "380px",
31
+ height: "520px",
32
+ zIndex: "10000",
33
+ borderRadius: "12px",
34
+ boxShadow: "0 4px 24px rgba(0,0,0,0.15)",
35
+ };
36
+ const PRESET_FULLSCREEN = {
37
+ ...BASE_CONTAINER,
38
+ position: "fixed",
39
+ top: "0",
40
+ left: "0",
41
+ width: "100vw",
42
+ height: "100vh",
43
+ zIndex: "10000",
44
+ padding: "2rem",
45
+ backgroundColor: "rgba(0,0,0,0.5)",
46
+ };
47
+ const PRESET_INLINE = {
48
+ ...BASE_CONTAINER,
49
+ position: "relative",
50
+ width: "100%",
51
+ height: "100%",
52
+ };
53
+ export const TRIGGER_BASE = {
54
+ position: "fixed",
55
+ bottom: "20px",
56
+ right: "20px",
57
+ width: "56px",
58
+ height: "56px",
59
+ borderRadius: "50%",
60
+ border: "none",
61
+ background: "#1a73e8",
62
+ color: "white",
63
+ cursor: "pointer",
64
+ zIndex: "10001",
65
+ boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
66
+ display: "flex",
67
+ alignItems: "center",
68
+ justifyContent: "center",
69
+ padding: "0",
70
+ };
71
+ /** CSS property objects for each positioning mode keyed by {@linkcode StylePreset} name */
72
+ export const STYLE_PRESETS = {
73
+ float: PRESET_FLOAT,
74
+ fullscreen: PRESET_FULLSCREEN,
75
+ inline: PRESET_INLINE,
76
+ };
77
+ export function applyPreset(container, preset, overrides) {
78
+ const base = STYLE_PRESETS[preset];
79
+ if (!base) {
80
+ throw new Error(`Unknown style preset: "${preset}"`);
81
+ }
82
+ Object.assign(container.style, base, overrides);
83
+ }
84
+ export function applyIframeBaseStyles(iframe) {
85
+ Object.assign(iframe.style, IFRAME_BASE);
86
+ }
@@ -0,0 +1,109 @@
1
+ /** Namespace prefix for all widget-provider postMessage types */
2
+ export declare const MSG_PREFIX = "@@__widget_provider__@@";
3
+ /** The structured envelope for all host <-> iframe messages */
4
+ export interface WidgetMessage<T = unknown> {
5
+ type: string;
6
+ payload?: T;
7
+ }
8
+ /** Built-in positioning modes for the widget container */
9
+ export type StylePreset = "float" | "fullscreen" | "inline";
10
+ /** Named animation presets for show/hide transitions */
11
+ export type AnimatePreset = "fade-scale" | "slide-up";
12
+ /** CSS overrides applied on top of a style preset */
13
+ export type StyleOverrides = Partial<CSSStyleDeclaration>;
14
+ /** Callback for handling a typed message payload from the widget iframe */
15
+ export type MessageHandler<T = unknown> = (payload: T) => void;
16
+ /** Function that removes a previously registered listener or subscription */
17
+ export type Unsubscribe = () => void;
18
+ /** Configuration options for {@linkcode provideWidget} */
19
+ export interface WidgetProviderOptions {
20
+ /** The URL of the SPA to embed */
21
+ widgetUrl: string;
22
+ /** DOM element to append the widget into. Defaults to document.body */
23
+ parentContainer?: HTMLElement;
24
+ /**
25
+ * Positioning mode. Defaults to "inline".
26
+ * - "float": fixed bottom-right chat-widget style
27
+ * - "fullscreen": covers viewport with optional backdrop
28
+ * - "inline": flows within parent container
29
+ */
30
+ stylePreset?: StylePreset;
31
+ /** CSS overrides applied to the container wrapper div */
32
+ styleOverrides?: StyleOverrides;
33
+ /**
34
+ * Allowed origin(s) for postMessage validation.
35
+ * If omitted, derived from widgetUrl.
36
+ * Use "*" to allow any origin (not recommended for production).
37
+ */
38
+ allowedOrigin?: string | string[];
39
+ /** Whether the widget starts visible. Defaults to true */
40
+ visible?: boolean;
41
+ /** Iframe sandbox attribute. Defaults to "allow-scripts allow-same-origin" */
42
+ sandbox?: string;
43
+ /** Additional iframe attributes (e.g. allow, referrerpolicy) */
44
+ iframeAttrs?: Record<string, string>;
45
+ /**
46
+ * Opt-in show/hide animation.
47
+ * - `true` → default "fade-scale" preset
48
+ * - string → named preset ("fade-scale" | "slide-up")
49
+ * - object → named preset + CSS transition override
50
+ */
51
+ animate?: boolean | AnimatePreset | {
52
+ preset?: AnimatePreset;
53
+ /** CSS transition shorthand override */
54
+ transition?: string;
55
+ };
56
+ /**
57
+ * Built-in floating trigger button.
58
+ * If `true`, uses default styles/icon. If object, allows customization.
59
+ * Automatically shown when widget is hidden, hidden when widget is visible.
60
+ */
61
+ trigger?: boolean | {
62
+ /** HTML content for the button (e.g. SVG icon). Defaults to a chat bubble SVG. */
63
+ content?: string;
64
+ /** CSS overrides for the trigger button */
65
+ style?: Partial<CSSStyleDeclaration>;
66
+ };
67
+ }
68
+ /** Reactive state tracked in the store */
69
+ export interface WidgetState {
70
+ visible: boolean;
71
+ ready: boolean;
72
+ destroyed: boolean;
73
+ preset: StylePreset;
74
+ }
75
+ /** Control API returned by {@linkcode provideWidget} */
76
+ export interface WidgetProviderApi {
77
+ /** Show the widget container */
78
+ show(): void;
79
+ /** Hide the widget container */
80
+ hide(): void;
81
+ /** Toggle visibility */
82
+ toggle(): void;
83
+ /** Remove iframe, event listeners, container from DOM. Irreversible. */
84
+ destroy(): void;
85
+ /** Switch to a specific style preset at runtime */
86
+ setPreset(preset: StylePreset): void;
87
+ /** Shortcut: switch to fullscreen preset */
88
+ maximize(): void;
89
+ /** Shortcut: switch back to the initial preset */
90
+ minimize(): void;
91
+ /** Request native browser fullscreen for the iframe */
92
+ requestNativeFullscreen(): Promise<void>;
93
+ /** Exit native browser fullscreen */
94
+ exitNativeFullscreen(): Promise<void>;
95
+ /** Send a typed message to the iframe */
96
+ send<T = unknown>(type: string, payload?: T): void;
97
+ /** Listen for a typed message from the iframe. Returns unsubscribe. */
98
+ onMessage<T = unknown>(type: string, handler: MessageHandler<T>): Unsubscribe;
99
+ /** Svelte-compatible store subscribe for reactive state */
100
+ subscribe(cb: (state: WidgetState) => void): Unsubscribe;
101
+ /** Direct getter for current state */
102
+ get(): WidgetState;
103
+ /** Direct reference to the iframe element */
104
+ readonly iframe: HTMLIFrameElement;
105
+ /** Direct reference to the container wrapper div */
106
+ readonly container: HTMLElement;
107
+ /** Direct reference to the trigger button element, or null if not configured */
108
+ readonly trigger: HTMLElement | null;
109
+ }
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ /** Namespace prefix for all widget-provider postMessage types */
2
+ export const MSG_PREFIX = "@@__widget_provider__@@";
@@ -0,0 +1,20 @@
1
+ import { type AnimateConfig } from "./style-presets.js";
2
+ import { type WidgetProviderApi, type WidgetProviderOptions } from "./types.js";
3
+ /**
4
+ * Resolve the list of allowed origins for postMessage validation.
5
+ * Uses explicit value if provided, otherwise derives from widgetUrl. Falls back to `["*"]`.
6
+ */
7
+ export declare function resolveAllowedOrigins(explicit: string | string[] | undefined, widgetUrl: string): string[];
8
+ /** Check whether a given origin is permitted by the allowed origins list. */
9
+ export declare function isOriginAllowed(origin: string, allowed: string[]): boolean;
10
+ /** Resolve the `animate` option into a concrete {@linkcode AnimateConfig} or `null` if disabled. */
11
+ export declare function resolveAnimateConfig(opt: WidgetProviderOptions["animate"]): AnimateConfig | null;
12
+ /**
13
+ * Create and embed an iframe-based widget into the host page.
14
+ *
15
+ * Creates a sandboxed iframe, applies the chosen style preset, wires up
16
+ * bidirectional postMessage communication, and returns a control API.
17
+ *
18
+ * @throws {Error} If `widgetUrl` is not provided.
19
+ */
20
+ export declare function provideWidget(options: WidgetProviderOptions): WidgetProviderApi;
@@ -0,0 +1,296 @@
1
+ import { createStore } from "@marianmeres/store";
2
+ import { ANIMATE_PRESETS, applyIframeBaseStyles, applyPreset, STYLE_PRESETS, TRIGGER_BASE, } from "./style-presets.js";
3
+ import { MSG_PREFIX, } from "./types.js";
4
+ const DEFAULT_TRIGGER_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`;
5
+ /**
6
+ * Resolve the list of allowed origins for postMessage validation.
7
+ * Uses explicit value if provided, otherwise derives from widgetUrl. Falls back to `["*"]`.
8
+ */
9
+ export function resolveAllowedOrigins(explicit, widgetUrl) {
10
+ if (explicit) {
11
+ return Array.isArray(explicit) ? explicit : [explicit];
12
+ }
13
+ try {
14
+ return [new URL(widgetUrl).origin];
15
+ }
16
+ catch {
17
+ return ["*"];
18
+ }
19
+ }
20
+ /** Check whether a given origin is permitted by the allowed origins list. */
21
+ export function isOriginAllowed(origin, allowed) {
22
+ if (allowed.includes("*"))
23
+ return true;
24
+ return allowed.includes(origin);
25
+ }
26
+ /** Resolve the `animate` option into a concrete {@linkcode AnimateConfig} or `null` if disabled. */
27
+ export function resolveAnimateConfig(opt) {
28
+ if (!opt)
29
+ return null;
30
+ if (opt === true)
31
+ return ANIMATE_PRESETS["fade-scale"];
32
+ if (typeof opt === "string")
33
+ return ANIMATE_PRESETS[opt] ?? null;
34
+ const base = ANIMATE_PRESETS[opt.preset ?? "fade-scale"];
35
+ if (!base)
36
+ return null;
37
+ return opt.transition ? { ...base, transition: opt.transition } : base;
38
+ }
39
+ /**
40
+ * Create and embed an iframe-based widget into the host page.
41
+ *
42
+ * Creates a sandboxed iframe, applies the chosen style preset, wires up
43
+ * bidirectional postMessage communication, and returns a control API.
44
+ *
45
+ * @throws {Error} If `widgetUrl` is not provided.
46
+ */
47
+ export function provideWidget(options) {
48
+ const { widgetUrl, parentContainer, stylePreset = "inline", styleOverrides = {}, allowedOrigin, visible = true, sandbox = "allow-scripts allow-same-origin", iframeAttrs = {}, } = options;
49
+ if (!widgetUrl) {
50
+ throw new Error("widgetUrl is required");
51
+ }
52
+ const origins = resolveAllowedOrigins(allowedOrigin, widgetUrl);
53
+ const initialPreset = stylePreset;
54
+ const anim = resolveAnimateConfig(options.animate);
55
+ // reactive state
56
+ const state = createStore({
57
+ visible,
58
+ ready: false,
59
+ destroyed: false,
60
+ preset: stylePreset,
61
+ });
62
+ // DOM
63
+ const container = document.createElement("div");
64
+ const iframe = document.createElement("iframe");
65
+ applyPreset(container, stylePreset, styleOverrides);
66
+ applyIframeBaseStyles(iframe);
67
+ iframe.src = widgetUrl;
68
+ if (sandbox) {
69
+ iframe.setAttribute("sandbox", sandbox);
70
+ }
71
+ iframe.setAttribute("allowfullscreen", "");
72
+ for (const [k, v] of Object.entries(iframeAttrs)) {
73
+ iframe.setAttribute(k, v);
74
+ }
75
+ container.appendChild(iframe);
76
+ if (anim) {
77
+ container.style.transition = anim.transition;
78
+ }
79
+ if (!visible) {
80
+ container.style.display = "none";
81
+ if (anim) {
82
+ Object.assign(container.style, anim.hidden);
83
+ }
84
+ }
85
+ // messaging
86
+ const messageHandlers = new Map();
87
+ function handleMessage(event) {
88
+ if (!isOriginAllowed(event.origin, origins))
89
+ return;
90
+ if (event.source !== iframe.contentWindow)
91
+ return;
92
+ const data = event.data;
93
+ if (!data || typeof data.type !== "string")
94
+ return;
95
+ if (!data.type.startsWith(MSG_PREFIX))
96
+ return;
97
+ // built-in control messages
98
+ const bareType = data.type.slice(MSG_PREFIX.length);
99
+ switch (bareType) {
100
+ case "ready":
101
+ state.update((s) => ({ ...s, ready: true }));
102
+ break;
103
+ case "maximize":
104
+ maximize();
105
+ break;
106
+ case "minimize":
107
+ minimize();
108
+ break;
109
+ case "hide":
110
+ hide();
111
+ break;
112
+ case "close":
113
+ destroy();
114
+ break;
115
+ case "setPreset":
116
+ if (typeof data.payload === "string" &&
117
+ data.payload in STYLE_PRESETS) {
118
+ setPreset(data.payload);
119
+ }
120
+ break;
121
+ case "nativeFullscreen":
122
+ requestNativeFullscreen();
123
+ break;
124
+ case "exitNativeFullscreen":
125
+ exitNativeFullscreen();
126
+ break;
127
+ }
128
+ const handlers = messageHandlers.get(data.type);
129
+ if (handlers) {
130
+ for (const h of handlers) {
131
+ try {
132
+ h(data.payload);
133
+ }
134
+ catch (e) {
135
+ console.warn("[widget-provider] message handler error:", e);
136
+ }
137
+ }
138
+ }
139
+ }
140
+ globalThis.addEventListener("message", handleMessage);
141
+ // append to DOM
142
+ const appendTarget = parentContainer || document.body;
143
+ appendTarget.appendChild(container);
144
+ // trigger button
145
+ const triggerOpts = options.trigger;
146
+ let triggerEl = null;
147
+ if (triggerOpts) {
148
+ triggerEl = document.createElement("button");
149
+ Object.assign(triggerEl.style, TRIGGER_BASE);
150
+ if (typeof triggerOpts === "object" && triggerOpts.style) {
151
+ Object.assign(triggerEl.style, triggerOpts.style);
152
+ }
153
+ const content = typeof triggerOpts === "object" && triggerOpts.content
154
+ ? triggerOpts.content
155
+ : DEFAULT_TRIGGER_ICON;
156
+ triggerEl.innerHTML = content;
157
+ if (visible) {
158
+ triggerEl.style.display = "none";
159
+ }
160
+ triggerEl.addEventListener("click", () => show());
161
+ appendTarget.appendChild(triggerEl);
162
+ }
163
+ // API
164
+ function show() {
165
+ if (state.get().destroyed)
166
+ return;
167
+ if (anim) {
168
+ Object.assign(container.style, anim.hidden);
169
+ container.style.display = "";
170
+ container.offsetHeight; // force reflow
171
+ Object.assign(container.style, anim.visible);
172
+ }
173
+ else {
174
+ container.style.display = "";
175
+ }
176
+ if (triggerEl)
177
+ triggerEl.style.display = "none";
178
+ state.update((s) => ({ ...s, visible: true }));
179
+ }
180
+ function hide() {
181
+ if (state.get().destroyed)
182
+ return;
183
+ if (triggerEl)
184
+ triggerEl.style.display = "";
185
+ state.update((s) => ({ ...s, visible: false }));
186
+ if (anim) {
187
+ Object.assign(container.style, anim.hidden);
188
+ const done = () => {
189
+ if (!state.get().visible) {
190
+ container.style.display = "none";
191
+ }
192
+ };
193
+ container.addEventListener("transitionend", done, {
194
+ once: true,
195
+ });
196
+ setTimeout(done, 250);
197
+ }
198
+ else {
199
+ container.style.display = "none";
200
+ }
201
+ }
202
+ function toggle() {
203
+ if (state.get().visible)
204
+ hide();
205
+ else
206
+ show();
207
+ }
208
+ function setPreset(preset) {
209
+ if (state.get().destroyed)
210
+ return;
211
+ if (!(preset in STYLE_PRESETS))
212
+ return;
213
+ container.style.cssText = "";
214
+ applyPreset(container, preset, styleOverrides);
215
+ if (anim) {
216
+ container.style.transition = anim.transition;
217
+ }
218
+ if (!state.get().visible) {
219
+ container.style.display = "none";
220
+ if (anim) {
221
+ Object.assign(container.style, anim.hidden);
222
+ }
223
+ }
224
+ state.update((s) => ({ ...s, preset }));
225
+ }
226
+ function maximize() {
227
+ setPreset("fullscreen");
228
+ }
229
+ function minimize() {
230
+ setPreset(initialPreset);
231
+ }
232
+ function requestNativeFullscreen() {
233
+ if (state.get().destroyed)
234
+ return Promise.resolve();
235
+ return iframe.requestFullscreen();
236
+ }
237
+ function exitNativeFullscreen() {
238
+ if (!document.fullscreenElement)
239
+ return Promise.resolve();
240
+ return document.exitFullscreen();
241
+ }
242
+ function destroy() {
243
+ if (state.get().destroyed)
244
+ return;
245
+ globalThis.removeEventListener("message", handleMessage);
246
+ messageHandlers.clear();
247
+ iframe.src = "about:blank";
248
+ container.remove();
249
+ triggerEl?.remove();
250
+ state.update((s) => ({
251
+ visible: false,
252
+ ready: false,
253
+ destroyed: true,
254
+ preset: s.preset,
255
+ }));
256
+ }
257
+ function send(type, payload) {
258
+ if (state.get().destroyed)
259
+ return;
260
+ iframe.contentWindow?.postMessage({ type: `${MSG_PREFIX}${type}`, payload }, origins[0] || "*");
261
+ }
262
+ function onMessage(type, handler) {
263
+ const prefixedType = `${MSG_PREFIX}${type}`;
264
+ if (!messageHandlers.has(prefixedType)) {
265
+ messageHandlers.set(prefixedType, new Set());
266
+ }
267
+ messageHandlers.get(prefixedType).add(handler);
268
+ return () => {
269
+ messageHandlers.get(prefixedType)?.delete(handler);
270
+ };
271
+ }
272
+ return {
273
+ show,
274
+ hide,
275
+ toggle,
276
+ destroy,
277
+ setPreset,
278
+ maximize,
279
+ minimize,
280
+ requestNativeFullscreen,
281
+ exitNativeFullscreen,
282
+ send,
283
+ onMessage,
284
+ subscribe: state.subscribe,
285
+ get: state.get,
286
+ get iframe() {
287
+ return iframe;
288
+ },
289
+ get container() {
290
+ return container;
291
+ },
292
+ get trigger() {
293
+ return triggerEl;
294
+ },
295
+ };
296
+ }
@@ -0,0 +1,50 @@
1
+ # Architecture
2
+
3
+ ## Overview
4
+
5
+ Single-function library that creates an iframe-based widget embedded in a host page. The host communicates with the iframe via `postMessage`, with origin validation and namespaced message types.
6
+
7
+ ## Component Map
8
+
9
+ ```
10
+ Host Page (provideWidget caller)
11
+
12
+ ├── Container <div> ← styled by preset (float/fullscreen/inline)
13
+ │ └── <iframe> ← loads widgetUrl, sandboxed
14
+
15
+ ├── Trigger <button> ← optional, auto-toggles visibility
16
+
17
+ └── State Store ← @marianmeres/store (visible, ready, destroyed, preset)
18
+ └── postMessage listener ← origin-validated, prefix-filtered
19
+ ```
20
+
21
+ ## Data Flow
22
+
23
+ ```
24
+ Host → iframe: widget.send(type, payload) → postMessage with MSG_PREFIX
25
+ iframe → Host: postMessage with MSG_PREFIX → handleMessage → built-in handlers + onMessage callbacks
26
+
27
+ Built-in control messages (from iframe):
28
+ ready, maximize, minimize, hide, close, setPreset, nativeFullscreen, exitNativeFullscreen
29
+ ```
30
+
31
+ ## Key Files
32
+
33
+ | File | Purpose |
34
+ |------|---------|
35
+ | `src/widget-provider.ts` | `provideWidget()` factory — creates DOM, wires messaging, returns API |
36
+ | `src/types.ts` | All types, interfaces, `MSG_PREFIX` constant |
37
+ | `src/style-presets.ts` | CSS preset objects, animation configs, apply functions |
38
+ | `src/mod.ts` | Public barrel export |
39
+
40
+ ## External Dependencies
41
+
42
+ | Dependency | Purpose |
43
+ |------------|---------|
44
+ | `@marianmeres/store` | Reactive state store (Svelte-compatible subscribe pattern) |
45
+
46
+ ## Security Boundaries
47
+
48
+ - **Origin validation**: `resolveAllowedOrigins()` derives from `widgetUrl` or uses explicit config. `isOriginAllowed()` checks incoming messages.
49
+ - **Iframe sandbox**: Defaults to `allow-scripts allow-same-origin`. Configurable via `sandbox` option.
50
+ - **Message namespace**: All messages prefixed with `@@__widget_provider__@@` to avoid collisions.
@@ -0,0 +1,65 @@
1
+ # Conventions
2
+
3
+ ## File Organisation
4
+
5
+ - Types and interfaces → `src/types.ts`
6
+ - Style/CSS logic → `src/style-presets.ts`
7
+ - Core implementation → `src/widget-provider.ts`
8
+ - Public exports → `src/mod.ts` (barrel)
9
+
10
+ ## Naming
11
+
12
+ - Factory function: `provideWidget()` (not `createWidget`)
13
+ - Type names: PascalCase (`WidgetProviderOptions`, `StylePreset`)
14
+ - Constants: UPPER_SNAKE (`MSG_PREFIX`, `STYLE_PRESETS`, `ANIMATE_PRESETS`)
15
+ - Internal helpers: camelCase, not exported from `mod.ts`
16
+
17
+ ## Patterns
18
+
19
+ ### Message Protocol
20
+ All messages use `WidgetMessage` envelope with `MSG_PREFIX`:
21
+
22
+ ```typescript
23
+ // Sending
24
+ send("myEvent", { data: 123 });
25
+ // Wire format: { type: "@@__widget_provider__@@myEvent", payload: { data: 123 } }
26
+
27
+ // Receiving
28
+ onMessage("myEvent", (payload) => { ... });
29
+ ```
30
+
31
+ ### Style Presets
32
+ Presets are plain `Partial<CSSStyleDeclaration>` objects applied via `Object.assign`:
33
+
34
+ ```typescript
35
+ // Adding a new preset:
36
+ // 1. Add to StylePreset union in types.ts
37
+ // 2. Create CSS object in style-presets.ts
38
+ // 3. Add to STYLE_PRESETS record
39
+ ```
40
+
41
+ ### State Management
42
+ State is a `@marianmeres/store` instance with `WidgetState` shape. Subscribe with Svelte-compatible pattern:
43
+
44
+ ```typescript
45
+ widget.subscribe((state) => { /* reactive */ });
46
+ widget.get(); // snapshot
47
+ ```
48
+
49
+ ## Anti-Patterns
50
+
51
+ - Do not create multiple `provideWidget()` instances targeting the same container
52
+ - Do not call API methods after `destroy()`
53
+ - Do not use `"*"` for `allowedOrigin` in production
54
+
55
+ ## Testing
56
+
57
+ - Pure utility functions (`resolveAllowedOrigins`, `isOriginAllowed`) are tested directly
58
+ - DOM-dependent `provideWidget()` requires browser environment (not tested in Deno unit tests)
59
+ - Run: `deno test`
60
+
61
+ ## Formatting
62
+
63
+ - Tabs for indentation
64
+ - 90 char line width
65
+ - Run `deno fmt` before committing
package/docs/tasks.md ADDED
@@ -0,0 +1,71 @@
1
+ # Tasks
2
+
3
+ ## Add a New Style Preset
4
+
5
+ ### Steps
6
+ 1. Add preset name to `StylePreset` union in `src/types.ts`
7
+ 2. Create CSS object in `src/style-presets.ts` (extend `BASE_CONTAINER`)
8
+ 3. Add to `STYLE_PRESETS` record in `src/style-presets.ts`
9
+
10
+ ### Template
11
+ ```typescript
12
+ // src/types.ts
13
+ export type StylePreset = "float" | "fullscreen" | "inline" | "new-preset";
14
+
15
+ // src/style-presets.ts
16
+ const PRESET_NEW: CSSProps = {
17
+ ...BASE_CONTAINER,
18
+ // CSS properties
19
+ };
20
+
21
+ export const STYLE_PRESETS: Record<StylePreset, CSSProps> = {
22
+ // ... existing
23
+ "new-preset": PRESET_NEW,
24
+ };
25
+ ```
26
+
27
+ ### Checklist
28
+ - [ ] Type union updated
29
+ - [ ] CSS object created
30
+ - [ ] STYLE_PRESETS record updated
31
+ - [ ] `deno test` passes
32
+
33
+ ## Add a New Animation Preset
34
+
35
+ ### Steps
36
+ 1. Add preset name to `AnimatePreset` union in `src/types.ts`
37
+ 2. Add `AnimateConfig` entry in `ANIMATE_PRESETS` in `src/style-presets.ts`
38
+
39
+ ### Checklist
40
+ - [ ] Type union updated
41
+ - [ ] Preset config added with `transition`, `hidden`, `visible` properties
42
+
43
+ ## Add a New Built-in Control Message
44
+
45
+ ### Steps
46
+ 1. Add `case` to `switch (bareType)` in `handleMessage()` in `src/widget-provider.ts`
47
+ 2. Implement handler function if needed
48
+
49
+ ### Template
50
+ ```typescript
51
+ case "myControl":
52
+ myControlFunction();
53
+ break;
54
+ ```
55
+
56
+ ### Checklist
57
+ - [ ] Case added to switch
58
+ - [ ] Handler implemented
59
+ - [ ] No-op if destroyed
60
+
61
+ ## Build and Publish
62
+
63
+ ### Steps
64
+ 1. Run `deno test` to verify
65
+ 2. Run `deno task release` to bump version
66
+ 3. Run `deno task publish` to publish to JSR + npm
67
+
68
+ ### Checklist
69
+ - [ ] Tests pass
70
+ - [ ] Version bumped
71
+ - [ ] Published to both registries
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@marianmeres/widget-provider",
3
+ "version": "1.0.2",
4
+ "type": "module",
5
+ "main": "dist/mod.js",
6
+ "types": "dist/mod.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/mod.d.ts",
10
+ "import": "./dist/mod.js"
11
+ }
12
+ },
13
+ "author": "Marian Meres",
14
+ "license": "MIT",
15
+ "dependencies": {
16
+ "@marianmeres/store": "^2.4.4"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/marianmeres/widget-provider.git"
21
+ },
22
+ "bugs": {
23
+ "url": "https://github.com/marianmeres/widget-provider/issues"
24
+ }
25
+ }