@medeo-plugin/sdk 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/rpc.js ADDED
@@ -0,0 +1,172 @@
1
+ import { channel } from "./index.js";
2
+ const PROTOCOL = 'medeo.rpc.v1';
3
+ const MAX_PENDING = 64;
4
+ const MAX_BYTES = 60 * 1024;
5
+ function packet(value) {
6
+ if (!value || typeof value !== 'object')
7
+ return false;
8
+ const p = value;
9
+ return p.protocol === PROTOCOL && ['request', 'result', 'error'].includes(p.kind) && typeof p.client === 'string' && p.client.length <= 80 && typeof p.id === 'string' && p.id.length <= 80;
10
+ }
11
+ /** Reject values JSON would silently drop or change, rather than corrupting an RPC argument. */
12
+ function checked(value) {
13
+ const seen = new Set();
14
+ function visit(v) {
15
+ if (v === null || typeof v === 'string' || typeof v === 'boolean')
16
+ return;
17
+ if (typeof v === 'number' && Number.isFinite(v))
18
+ return;
19
+ if (!v || typeof v !== 'object' || (!Array.isArray(v) && Object.getPrototypeOf(v) !== Object.prototype && Object.getPrototypeOf(v) !== null) || seen.has(v))
20
+ throw new Error('rpc_invalid_json');
21
+ seen.add(v);
22
+ const array = Array.isArray(v);
23
+ if (array && Object.keys(v).length !== v.length)
24
+ throw new Error('rpc_invalid_json');
25
+ for (const key of Reflect.ownKeys(v)) {
26
+ if (array && key === 'length')
27
+ continue;
28
+ const descriptor = Object.getOwnPropertyDescriptor(v, key);
29
+ if (typeof key !== 'string' || !descriptor.enumerable || !('value' in descriptor) ||
30
+ (array && (!/^(0|[1-9]\d*)$/.test(key) || Number(key) >= v.length)))
31
+ throw new Error('rpc_invalid_json');
32
+ visit(descriptor.value);
33
+ }
34
+ seen.delete(v);
35
+ }
36
+ visit(value);
37
+ const json = JSON.stringify(value);
38
+ if (new TextEncoder().encode(json).length > MAX_BYTES)
39
+ throw new Error('rpc_message_too_large');
40
+ return JSON.parse(json);
41
+ }
42
+ /** Register in activate, then push the returned disposable into context.subscriptions.
43
+ * Requests only reach this plugin's worker; this is not a cross-plugin service API.
44
+ */
45
+ export function defineWorkerApi(handlers, options = {}) {
46
+ for (const name of ['then', '$dispose']) {
47
+ if (Object.hasOwn(handlers, name))
48
+ throw new Error(`rpc_reserved_method: ${name}`);
49
+ }
50
+ const transport = options.transport ?? channel;
51
+ const active = new Set();
52
+ let stopped = false;
53
+ const respond = (p) => {
54
+ if (stopped)
55
+ return;
56
+ const message = checked(p);
57
+ void Promise.resolve().then(() => { if (!stopped)
58
+ return transport.post(message); }).catch(() => { });
59
+ };
60
+ const off = transport.onMessage(message => {
61
+ if (stopped || !packet(message) || message.kind !== 'request')
62
+ return;
63
+ const key = JSON.stringify([message.client, message.id]);
64
+ if (active.has(key))
65
+ return;
66
+ const reply = { protocol: PROTOCOL, client: message.client, id: message.id };
67
+ if (active.size >= MAX_PENDING) {
68
+ respond({ ...reply, kind: 'error', error: 'rpc_busy' });
69
+ return;
70
+ }
71
+ if (typeof message.method !== 'string' || !Object.hasOwn(handlers, message.method) || typeof handlers[message.method] !== 'function' || !Array.isArray(message.args)) {
72
+ respond({ ...reply, kind: 'error', error: 'rpc_method_not_found' });
73
+ return;
74
+ }
75
+ active.add(key);
76
+ void Promise.resolve().then(() => {
77
+ if (stopped)
78
+ return;
79
+ const request = checked(message);
80
+ return handlers[request.method](...request.args);
81
+ }).then(value => {
82
+ respond({ ...reply, kind: 'result', value: checked(value === undefined ? null : value) });
83
+ }).catch(error => {
84
+ let detail = 'rpc_failed';
85
+ try {
86
+ detail = String(error instanceof Error ? error.message : error).slice(0, 1000);
87
+ }
88
+ catch { }
89
+ respond({ ...reply, kind: 'error', error: detail });
90
+ }).finally(() => active.delete(key));
91
+ });
92
+ return { dispose() { stopped = true; off(); active.clear(); } };
93
+ }
94
+ /** Create once per window, dispose on unmount. Timeout/disposal cancels waiting,
95
+ * not a worker side effect that already started. All arguments/results are JSON.
96
+ */
97
+ export function useWorkerApi(options = {}) {
98
+ const transport = options.transport ?? channel;
99
+ const timeoutMs = options.timeoutMs ?? 30_000;
100
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 300_000)
101
+ throw new Error('rpc_invalid_timeout');
102
+ const client = globalThis.crypto.randomUUID();
103
+ let sequence = 0, stopped = false;
104
+ const pending = new Map();
105
+ const settle = (id, value, error) => {
106
+ const request = pending.get(id);
107
+ if (!request)
108
+ return;
109
+ pending.delete(id);
110
+ clearTimeout(request.timer);
111
+ if (error !== undefined)
112
+ request.reject(new Error(error));
113
+ else
114
+ request.resolve(value);
115
+ };
116
+ const off = transport.onMessage(message => {
117
+ if (!packet(message) || message.client !== client || message.kind === 'request')
118
+ return;
119
+ try {
120
+ const response = checked(message);
121
+ if (response.kind === 'error')
122
+ settle(response.id, null, typeof response.error === 'string' ? response.error : 'rpc_failed');
123
+ else
124
+ settle(response.id, checked(response.value));
125
+ }
126
+ catch (error) {
127
+ settle(message.id, null, error instanceof Error ? error.message : 'rpc_invalid_json');
128
+ }
129
+ });
130
+ const dispose = () => {
131
+ if (stopped)
132
+ return;
133
+ stopped = true;
134
+ off();
135
+ options.signal?.removeEventListener('abort', dispose);
136
+ for (const id of pending.keys())
137
+ settle(id, null, 'rpc_disposed');
138
+ };
139
+ options.signal?.addEventListener('abort', dispose, { once: true });
140
+ if (options.signal?.aborted)
141
+ dispose();
142
+ return new Proxy(Object.create(null), {
143
+ get(_target, method) {
144
+ if (method === '$dispose')
145
+ return dispose;
146
+ if (method === 'then' || typeof method !== 'string')
147
+ return undefined;
148
+ return (...args) => {
149
+ if (stopped)
150
+ return Promise.reject(new Error('rpc_disposed'));
151
+ if (pending.size >= MAX_PENDING)
152
+ return Promise.reject(new Error('rpc_busy'));
153
+ const id = String(++sequence);
154
+ let message;
155
+ try {
156
+ message = checked({ protocol: PROTOCOL, kind: 'request', client, id, method, args });
157
+ }
158
+ catch (error) {
159
+ return Promise.reject(error);
160
+ }
161
+ return new Promise((resolve, reject) => {
162
+ const timer = setTimeout(() => settle(id, null, 'rpc_timeout'), timeoutMs);
163
+ pending.set(id, { resolve, reject, timer });
164
+ void Promise.resolve().then(() => {
165
+ if (!stopped && pending.has(id))
166
+ return transport.post(message);
167
+ }).catch(error => settle(id, null, error instanceof Error ? error.message : 'rpc_send_failed'));
168
+ });
169
+ };
170
+ },
171
+ });
172
+ }
@@ -0,0 +1,28 @@
1
+ import type { PluginAPI, PluginContext, PlatformEvent, PluginLocaleState } from './index.ts';
2
+ export type FakeHandler = (args: Record<string, unknown>) => unknown | Promise<unknown>;
3
+ type CommandHandler = Parameters<PluginContext['commands']['register']>[1];
4
+ export interface FakeHostOptions {
5
+ storage?: Record<string, unknown>;
6
+ /** Declared configuration keys and their initial/default values. */
7
+ config?: Record<string, unknown>;
8
+ handlers?: Record<string, FakeHandler>;
9
+ commands?: Record<string, CommandHandler>;
10
+ }
11
+ /** Offline bridge for real SDK calls. No OS, permissions, persistence or manifest
12
+ * validation is simulated. Channel messages loop back to this host's listeners.
13
+ * Install only within a serial test scope (globals cannot isolate concurrent tests).
14
+ * Every unimplemented capability fails unless supplied in `handlers`.
15
+ */
16
+ export declare function createFakeHost(options?: FakeHostOptions): {
17
+ api: PluginAPI;
18
+ context: PluginContext;
19
+ /** Explicit installation: importing this module never changes the global. */
20
+ install(): () => void;
21
+ emitEvent: (event: PlatformEvent) => void;
22
+ emitMessage: (message: unknown) => void;
23
+ emitLocaleChanged: (state: PluginLocaleState) => void;
24
+ dispose(): Promise<void>;
25
+ };
26
+ /** Installs a bridge for a callback and restores it even when the callback throws. */
27
+ export declare function withFakeHost<T>(options: FakeHostOptions, run: (host: ReturnType<typeof createFakeHost>) => T | Promise<T>): Promise<T>;
28
+ export {};
@@ -0,0 +1,161 @@
1
+ import { METHODS } from "./registry.js";
2
+ const installations = [];
3
+ function installAPI(api) {
4
+ const entry = { api, previous: Object.getOwnPropertyDescriptor(globalThis, 'medeoPlugin'), removed: false };
5
+ Object.defineProperty(globalThis, 'medeoPlugin', { configurable: true, writable: true, value: api });
6
+ installations.push(entry);
7
+ return () => {
8
+ if (entry.removed)
9
+ return;
10
+ entry.removed = true;
11
+ while (installations.at(-1)?.removed) {
12
+ const top = installations.pop();
13
+ // Never overwrite a bridge installed by unrelated test code.
14
+ if (Object.getOwnPropertyDescriptor(globalThis, 'medeoPlugin')?.value !== top.api)
15
+ continue;
16
+ if (top.previous)
17
+ Object.defineProperty(globalThis, 'medeoPlugin', top.previous);
18
+ else
19
+ Reflect.deleteProperty(globalThis, 'medeoPlugin');
20
+ }
21
+ };
22
+ }
23
+ /** Offline bridge for real SDK calls. No OS, permissions, persistence or manifest
24
+ * validation is simulated. Channel messages loop back to this host's listeners.
25
+ * Install only within a serial test scope (globals cannot isolate concurrent tests).
26
+ * Every unimplemented capability fails unless supplied in `handlers`.
27
+ */
28
+ export function createFakeHost(options = {}) {
29
+ const clone = (value) => structuredClone(value);
30
+ const storage = new Map(Object.entries(clone(options.storage ?? {})));
31
+ const config = new Map(Object.entries(clone(options.config ?? {})));
32
+ const handlers = new Map(Object.entries(options.handlers ?? {}));
33
+ const commands = new Map();
34
+ const messages = new Set();
35
+ const events = new Set();
36
+ const locales = new Set();
37
+ const restores = new Set();
38
+ let disposed = false;
39
+ const assertActive = () => { if (disposed)
40
+ throw new Error('fake_host_disposed'); };
41
+ const subscribe = (listeners, callback) => {
42
+ assertActive();
43
+ listeners.add(callback);
44
+ return () => { listeners.delete(callback); };
45
+ };
46
+ const emit = (listeners, value) => {
47
+ assertActive();
48
+ for (const listener of [...listeners])
49
+ listener(clone(value));
50
+ };
51
+ const key = (args) => {
52
+ if (typeof args.key !== 'string')
53
+ throw new Error('invalid_key');
54
+ return args.key;
55
+ };
56
+ const builtins = {
57
+ 'storage.get': args => clone(storage.get(key(args)) ?? null),
58
+ 'storage.set': args => { storage.set(key(args), clone(args.value)); return null; },
59
+ 'storage.remove': args => { storage.delete(key(args)); return null; },
60
+ 'config.get': args => {
61
+ if (args.key === undefined)
62
+ return Object.fromEntries(config);
63
+ const name = key(args);
64
+ if (!config.has(name))
65
+ throw new Error(`unknown_configuration: ${name}`);
66
+ return config.get(name);
67
+ },
68
+ 'config.set': args => {
69
+ const name = key(args);
70
+ if (!config.has(name))
71
+ throw new Error(`unknown_configuration: ${name}`);
72
+ config.set(name, clone(args.value));
73
+ emit(events, { type: 'signal', channel: 'config' });
74
+ return null;
75
+ },
76
+ };
77
+ const api = {
78
+ async invoke(method, args = {}) {
79
+ assertActive();
80
+ const handler = handlers.get(method) ?? (Object.hasOwn(builtins, method) ? builtins[method] : undefined);
81
+ if (!handler)
82
+ throw new Error(`${Object.hasOwn(METHODS, method) ? 'unimplemented_method' : 'unknown_method'}: ${method}`);
83
+ return clone(await handler(clone(args)));
84
+ },
85
+ async run(command, args) {
86
+ assertActive();
87
+ const handler = commands.get(command);
88
+ if (!handler)
89
+ throw new Error(`unregistered_command: ${command}`);
90
+ return clone(await handler(clone(args)));
91
+ },
92
+ async post(message) { emit(messages, message); },
93
+ onMessage: callback => subscribe(messages, callback),
94
+ onEvent: callback => subscribe(events, callback),
95
+ onLocaleChanged: callback => subscribe(locales, callback),
96
+ };
97
+ const context = {
98
+ subscriptions: [],
99
+ commands: {
100
+ register(id, handler) {
101
+ assertActive();
102
+ if (commands.has(id))
103
+ throw new Error(`duplicate_command: ${id}`);
104
+ commands.set(id, handler);
105
+ const disposable = { dispose() { if (commands.get(id) === handler)
106
+ commands.delete(id); } };
107
+ context.subscriptions.push(disposable);
108
+ return disposable;
109
+ },
110
+ },
111
+ };
112
+ for (const [id, handler] of Object.entries(options.commands ?? {}))
113
+ context.commands.register(id, handler);
114
+ return {
115
+ api, context,
116
+ /** Explicit installation: importing this module never changes the global. */
117
+ install() {
118
+ assertActive();
119
+ const restore = installAPI(api);
120
+ const off = () => { restore(); restores.delete(off); };
121
+ restores.add(off);
122
+ return off;
123
+ },
124
+ emitEvent: (event) => emit(events, event),
125
+ emitMessage: (message) => emit(messages, message),
126
+ emitLocaleChanged: (state) => emit(locales, state),
127
+ async dispose() {
128
+ if (disposed)
129
+ return;
130
+ disposed = true;
131
+ for (const restore of [...restores].reverse())
132
+ restore();
133
+ messages.clear();
134
+ events.clear();
135
+ locales.clear();
136
+ commands.clear();
137
+ const errors = [];
138
+ for (const subscription of context.subscriptions.splice(0).reverse()) {
139
+ try {
140
+ await subscription.dispose();
141
+ }
142
+ catch (error) {
143
+ errors.push(error);
144
+ }
145
+ }
146
+ if (errors.length)
147
+ throw new AggregateError(errors, 'fake_host_cleanup_failed');
148
+ },
149
+ };
150
+ }
151
+ /** Installs a bridge for a callback and restores it even when the callback throws. */
152
+ export async function withFakeHost(options, run) {
153
+ const host = createFakeHost(options);
154
+ host.install();
155
+ try {
156
+ return await run(host);
157
+ }
158
+ finally {
159
+ await host.dispose();
160
+ }
161
+ }
@@ -0,0 +1,4 @@
1
+ /** Layout only: colors, type and dimensions remain owned by /_medeo/theme.css. */
2
+ export declare const uiStyles = "\n.medeo-ui{font-family:var(--font-family-plain);color:var(--color-surface-on-surface);font-size:var(--type-label-large-size);line-height:var(--type-label-large-leading)}\n.medeo-card{padding:var(--space-s5);border:1px solid var(--color-surface-outline-variant);border-radius:var(--shape-radius-16);background:var(--color-surface-surface-container-lowest);display:grid;gap:var(--space-s3)}\n.medeo-card h2,.medeo-empty h2{margin:0;font-size:var(--type-title-medium-size);line-height:var(--type-title-medium-leading);font-weight:var(--type-title-medium-weight);letter-spacing:var(--type-title-medium-tracking)}\n.medeo-description{margin:0;color:var(--color-surface-on-surface-variant)}\n.medeo-pill{display:inline-flex;align-items:center;justify-self:start;padding:var(--space-s1) var(--space-s3);border-radius:var(--shape-radius-full);background:var(--medeo-selected);font-size:var(--type-label-medium-size);line-height:var(--type-label-medium-leading);font-weight:var(--type-label-medium-prominent-weight)}\n.medeo-pill[data-tone=error]{color:var(--color-schemes-error);background:var(--medeo-error-wash)}\n.medeo-pill[data-tone=neutral]{background:var(--medeo-hover)}\n.medeo-toggle{display:inline-flex;align-items:center;gap:var(--space-s2);min-height:var(--space-s8);cursor:pointer}\n.medeo-toggle input{width:var(--space-s4);height:var(--space-s4);accent-color:var(--color-schemes-primary)}\n.medeo-toggle:has(input:disabled){opacity:.45;cursor:default}\n.medeo-segmented{display:flex;flex-wrap:wrap;gap:var(--space-s1);padding:var(--space-s1);border:1px solid var(--color-surface-outline-variant);border-radius:var(--shape-radius-12)}\n.medeo-segmented button{font:inherit;color:inherit;border:0;border-radius:var(--shape-radius-8);background:transparent;min-height:var(--space-s8);padding:var(--space-s2) var(--space-s3);cursor:pointer}\n.medeo-segmented button[aria-checked=true]{background:var(--color-schemes-primary);color:var(--color-schemes-on-primary)}\n.medeo-segmented button:hover:not(:disabled):not([aria-checked=true]){background:var(--medeo-hover)}\n.medeo-segmented button:disabled{opacity:.45;cursor:default}\n.medeo-ui :focus-visible{outline:2px solid var(--color-schemes-primary);outline-offset:2px}\n.medeo-list{list-style:none;margin:0;padding:0;display:grid;gap:var(--space-s2)}\n.medeo-list>li{padding:var(--space-s3);border-bottom:1px solid var(--color-surface-outline-variant)}\n.medeo-empty{display:grid;gap:var(--space-s2);padding:var(--space-s7);text-align:center;border:1px dashed var(--color-surface-outline);border-radius:var(--shape-radius-12)}\n";
3
+ /** Explicit and idempotent; importing this module has no DOM side effects. */
4
+ export declare function installStyles(doc?: Document): void;
@@ -0,0 +1,31 @@
1
+ /** Layout only: colors, type and dimensions remain owned by /_medeo/theme.css. */
2
+ export const uiStyles = `
3
+ .medeo-ui{font-family:var(--font-family-plain);color:var(--color-surface-on-surface);font-size:var(--type-label-large-size);line-height:var(--type-label-large-leading)}
4
+ .medeo-card{padding:var(--space-s5);border:1px solid var(--color-surface-outline-variant);border-radius:var(--shape-radius-16);background:var(--color-surface-surface-container-lowest);display:grid;gap:var(--space-s3)}
5
+ .medeo-card h2,.medeo-empty h2{margin:0;font-size:var(--type-title-medium-size);line-height:var(--type-title-medium-leading);font-weight:var(--type-title-medium-weight);letter-spacing:var(--type-title-medium-tracking)}
6
+ .medeo-description{margin:0;color:var(--color-surface-on-surface-variant)}
7
+ .medeo-pill{display:inline-flex;align-items:center;justify-self:start;padding:var(--space-s1) var(--space-s3);border-radius:var(--shape-radius-full);background:var(--medeo-selected);font-size:var(--type-label-medium-size);line-height:var(--type-label-medium-leading);font-weight:var(--type-label-medium-prominent-weight)}
8
+ .medeo-pill[data-tone=error]{color:var(--color-schemes-error);background:var(--medeo-error-wash)}
9
+ .medeo-pill[data-tone=neutral]{background:var(--medeo-hover)}
10
+ .medeo-toggle{display:inline-flex;align-items:center;gap:var(--space-s2);min-height:var(--space-s8);cursor:pointer}
11
+ .medeo-toggle input{width:var(--space-s4);height:var(--space-s4);accent-color:var(--color-schemes-primary)}
12
+ .medeo-toggle:has(input:disabled){opacity:.45;cursor:default}
13
+ .medeo-segmented{display:flex;flex-wrap:wrap;gap:var(--space-s1);padding:var(--space-s1);border:1px solid var(--color-surface-outline-variant);border-radius:var(--shape-radius-12)}
14
+ .medeo-segmented button{font:inherit;color:inherit;border:0;border-radius:var(--shape-radius-8);background:transparent;min-height:var(--space-s8);padding:var(--space-s2) var(--space-s3);cursor:pointer}
15
+ .medeo-segmented button[aria-checked=true]{background:var(--color-schemes-primary);color:var(--color-schemes-on-primary)}
16
+ .medeo-segmented button:hover:not(:disabled):not([aria-checked=true]){background:var(--medeo-hover)}
17
+ .medeo-segmented button:disabled{opacity:.45;cursor:default}
18
+ .medeo-ui :focus-visible{outline:2px solid var(--color-schemes-primary);outline-offset:2px}
19
+ .medeo-list{list-style:none;margin:0;padding:0;display:grid;gap:var(--space-s2)}
20
+ .medeo-list>li{padding:var(--space-s3);border-bottom:1px solid var(--color-surface-outline-variant)}
21
+ .medeo-empty{display:grid;gap:var(--space-s2);padding:var(--space-s7);text-align:center;border:1px dashed var(--color-surface-outline);border-radius:var(--shape-radius-12)}
22
+ `;
23
+ /** Explicit and idempotent; importing this module has no DOM side effects. */
24
+ export function installStyles(doc = document) {
25
+ if (doc.querySelector('style[data-medeo-ui]'))
26
+ return;
27
+ const style = doc.createElement('style');
28
+ style.dataset.medeoUi = '';
29
+ style.textContent = uiStyles;
30
+ doc.head.append(style);
31
+ }
package/dist/ui.d.ts ADDED
@@ -0,0 +1,50 @@
1
+ export { installStyles, uiStyles } from './ui/styles.js';
2
+ export interface Identified {
3
+ testid: string;
4
+ }
5
+ export interface CardOptions extends Identified {
6
+ title: string;
7
+ description?: string;
8
+ }
9
+ export interface PillOptions extends Identified {
10
+ label: string;
11
+ tone?: 'neutral' | 'info' | 'error';
12
+ }
13
+ export interface ToggleOptions extends Identified {
14
+ label: string;
15
+ checked: boolean;
16
+ disabled?: boolean;
17
+ onChange: (checked: boolean) => void;
18
+ }
19
+ export interface Segment<T extends string = string> {
20
+ value: T;
21
+ label: string;
22
+ disabled?: boolean;
23
+ }
24
+ export interface SegmentedOptions<T extends string = string> extends Identified {
25
+ label: string;
26
+ options: readonly Segment<T>[];
27
+ value: T;
28
+ disabled?: boolean;
29
+ onChange: (value: T) => void;
30
+ }
31
+ export interface ListOptions extends Identified {
32
+ label: string;
33
+ }
34
+ export type Child = Node | string | number | null | undefined | false;
35
+ export declare function card(options: CardOptions, ...children: Child[]): HTMLElement;
36
+ export declare function pill(options: PillOptions): HTMLSpanElement;
37
+ /** Native checkbox keyboard behavior (Space), with switch semantics. */
38
+ export declare function toggle(options: ToggleOptions): HTMLLabelElement & {
39
+ input: HTMLInputElement;
40
+ };
41
+ /** Shared radio keyboard navigation; disabled choices are skipped, edges wrap. */
42
+ export declare function segmentTarget<T extends string>(options: readonly Segment<T>[], index: number, key: string, rtl?: boolean): number | undefined;
43
+ export declare function segmentTabIndex<T extends string>(options: readonly Segment<T>[], value: T, index: number): number;
44
+ export declare function segmented<T extends string>(options: SegmentedOptions<T>): HTMLDivElement & {
45
+ setValue(value: T): void;
46
+ setDisabled(disabled: boolean): void;
47
+ };
48
+ /** Each child becomes one semantic list item; caller owns empty-state wording. */
49
+ export declare function list(options: ListOptions, ...items: Child[]): HTMLUListElement;
50
+ export declare function emptyState(options: CardOptions): HTMLElement;
package/dist/ui.js ADDED
@@ -0,0 +1,115 @@
1
+ export { installStyles, uiStyles } from './ui/styles.js';
2
+ function node(tag, className, testid, text) {
3
+ const el = document.createElement(tag);
4
+ el.className = className;
5
+ if (testid !== undefined)
6
+ el.dataset.testid = testid;
7
+ if (text !== undefined)
8
+ el.textContent = text;
9
+ return el;
10
+ }
11
+ function append(el, children) {
12
+ for (const child of children)
13
+ if (child !== null && child !== undefined && child !== false)
14
+ el.append(typeof child === 'number' ? String(child) : child);
15
+ }
16
+ export function card(options, ...children) {
17
+ const root = node('section', 'medeo-ui medeo-card', options.testid);
18
+ root.setAttribute('aria-label', options.title);
19
+ root.append(node('h2', '', `${options.testid}.title`, options.title));
20
+ if (options.description !== undefined)
21
+ root.append(node('p', 'medeo-description', undefined, options.description));
22
+ append(root, children);
23
+ return root;
24
+ }
25
+ export function pill(options) {
26
+ const root = node('span', 'medeo-ui medeo-pill', options.testid, options.label);
27
+ root.dataset.tone = options.tone ?? 'info';
28
+ return root;
29
+ }
30
+ /** Native checkbox keyboard behavior (Space), with switch semantics. */
31
+ export function toggle(options) {
32
+ const root = node('label', 'medeo-ui medeo-toggle', `${options.testid}.root`);
33
+ const input = node('input', '', options.testid);
34
+ input.type = 'checkbox';
35
+ input.setAttribute('role', 'switch');
36
+ input.checked = options.checked;
37
+ input.disabled = options.disabled ?? false;
38
+ input.addEventListener('change', () => options.onChange(input.checked));
39
+ root.append(input, node('span', '', undefined, options.label));
40
+ return Object.assign(root, { input });
41
+ }
42
+ /** Shared radio keyboard navigation; disabled choices are skipped, edges wrap. */
43
+ export function segmentTarget(options, index, key, rtl = false) {
44
+ const enabled = options.map((o, i) => o.disabled ? -1 : i).filter(i => i >= 0);
45
+ if (!enabled.length)
46
+ return undefined;
47
+ if (key === 'Home')
48
+ return enabled[0];
49
+ if (key === 'End')
50
+ return enabled[enabled.length - 1];
51
+ let direction = key === 'ArrowDown' ? 1 : key === 'ArrowUp' ? -1 : key === 'ArrowRight' ? (rtl ? -1 : 1) : key === 'ArrowLeft' ? (rtl ? 1 : -1) : 0;
52
+ if (!direction)
53
+ return undefined;
54
+ return enabled[(enabled.indexOf(index) + direction + enabled.length) % enabled.length];
55
+ }
56
+ export function segmentTabIndex(options, value, index) {
57
+ let active = options.findIndex(o => o.value === value && !o.disabled);
58
+ if (active < 0)
59
+ active = options.findIndex(o => !o.disabled);
60
+ return index === active ? 0 : -1;
61
+ }
62
+ export function segmented(options) {
63
+ const root = node('div', 'medeo-ui medeo-segmented', options.testid);
64
+ root.setAttribute('role', 'radiogroup');
65
+ root.setAttribute('aria-label', options.label);
66
+ let value = options.value, disabled = options.disabled ?? false;
67
+ const buttons = options.options.map((option, index) => {
68
+ const button = node('button', '', `${options.testid}.${index}`, option.label);
69
+ button.type = 'button';
70
+ button.setAttribute('role', 'radio');
71
+ button.addEventListener('click', () => {
72
+ if (disabled || option.disabled)
73
+ return;
74
+ value = option.value;
75
+ render();
76
+ options.onChange(value);
77
+ });
78
+ button.addEventListener('keydown', event => {
79
+ if (disabled)
80
+ return;
81
+ const target = segmentTarget(options.options, index, event.key, getComputedStyle(root).direction === 'rtl');
82
+ if (target === undefined)
83
+ return;
84
+ event.preventDefault();
85
+ buttons[target].focus();
86
+ buttons[target].click();
87
+ });
88
+ return button;
89
+ });
90
+ function render() {
91
+ buttons.forEach((button, index) => {
92
+ button.disabled = disabled || !!options.options[index].disabled;
93
+ button.tabIndex = disabled ? -1 : segmentTabIndex(options.options, value, index);
94
+ button.setAttribute('aria-checked', String(options.options[index].value === value));
95
+ });
96
+ }
97
+ root.append(...buttons);
98
+ render();
99
+ return Object.assign(root, { setValue(next) { value = next; render(); }, setDisabled(next) { disabled = next; render(); } });
100
+ }
101
+ /** Each child becomes one semantic list item; caller owns empty-state wording. */
102
+ export function list(options, ...items) {
103
+ const root = node('ul', 'medeo-ui medeo-list', options.testid);
104
+ root.setAttribute('aria-label', options.label);
105
+ items.forEach((item, index) => { const li = node('li', '', `${options.testid}.${index}`); append(li, [item]); root.append(li); });
106
+ return root;
107
+ }
108
+ export function emptyState(options) {
109
+ const root = node('section', 'medeo-ui medeo-empty', options.testid);
110
+ root.setAttribute('aria-label', options.title);
111
+ root.append(node('h2', '', undefined, options.title));
112
+ if (options.description !== undefined)
113
+ root.append(node('p', 'medeo-description', undefined, options.description));
114
+ return root;
115
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@medeo-plugin/sdk",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "TypeScript SDK for Medeo desktop plugins: one import for the sandboxed worker, its windows and Node plugins.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -22,6 +22,26 @@
22
22
  "import": "./dist/index.js",
23
23
  "default": "./dist/index.js"
24
24
  },
25
+ "./rpc": {
26
+ "types": "./dist/rpc.d.ts",
27
+ "import": "./dist/rpc.js",
28
+ "default": "./dist/rpc.js"
29
+ },
30
+ "./testing": {
31
+ "types": "./dist/testing.d.ts",
32
+ "import": "./dist/testing.js",
33
+ "default": "./dist/testing.js"
34
+ },
35
+ "./ui": {
36
+ "types": "./dist/ui.d.ts",
37
+ "import": "./dist/ui.js",
38
+ "default": "./dist/ui.js"
39
+ },
40
+ "./ui/react": {
41
+ "types": "./dist/react.d.ts",
42
+ "import": "./dist/react.js",
43
+ "default": "./dist/react.js"
44
+ },
25
45
  "./package.json": "./package.json"
26
46
  },
27
47
  "types": "./dist/index.d.ts",
@@ -34,6 +54,14 @@
34
54
  "build": "tsc -p tsconfig.build.json",
35
55
  "prepack": "npm run build"
36
56
  },
57
+ "peerDependencies": {
58
+ "react": "^18.0.0 || ^19.0.0"
59
+ },
60
+ "peerDependenciesMeta": {
61
+ "react": {
62
+ "optional": true
63
+ }
64
+ },
37
65
  "engines": {
38
66
  "node": ">=22"
39
67
  }
package/src/generated.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // Generated by platform-contract; do not edit.
2
2
  export type JsonValue = number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null;
3
- export type Permission = "storage" | "creation_open" | { "file_access": Array<string> } | { "file_full_access": Array<string> } | { "network": Array<string> } | "agent_api" | "clipboard_read" | "clipboard_write" | "screen_capture" | "camera" | "microphone" | "system_audio" | "applications" | "global_shortcuts" | "notifications" | "tray" | "app_windows" | "accessibility" | "input_method" | { "external_open": Array<string> } | { "browser_view": Array<string> } | "system_info" | "secrets" | "background_tasks" | "power" | { "process_spawn": Array<string> } | "local_server" | "printing" | "contacts" | "calendar" | "location" | "device_access" | { "approvals": Array<string> } | "node";
3
+ export type Permission = "storage" | "creation_open" | { "file_access": Array<string> } | { "file_full_access": Array<string> } | { "network": Array<string> } | "agent_api" | "clipboard_read" | "clipboard_write" | "screen_capture" | "camera" | "microphone" | "system_audio" | "applications" | "global_shortcuts" | "notifications" | "tray" | "app_windows" | "accessibility" | "window_management" | "input_method" | { "external_open": Array<string> } | { "browser_view": Array<string> } | "system_info" | "secrets" | "background_tasks" | "power" | { "process_spawn": Array<string> } | "local_server" | "printing" | "contacts" | "calendar" | "location" | "device_access" | { "approvals": Array<string> } | "node";
4
4
  export type PluginCommand = { id: string, title: string, };
5
5
  export type PluginLocalization = { default_locale: string, catalogs: { [key in string]?: string }, };
6
6
  export type Runtime = "sandbox" | "node";
@@ -76,6 +76,27 @@ export type PackageFile = { size: number, sha256: string, };
76
76
  export type PackageIndex = { manifest: Manifest, files: { [key in string]?: PackageFile }, };
77
77
  export type Asset = { mime: string, path: string, size: number, sha256: string, };
78
78
  export type PackageSignature = { format: number, public_key: string, signature: string, index: string, };
79
+ export type About = {
80
+ /**
81
+ * Display name of the author, at most 80 characters. Official packages show Medeo.
82
+ */
83
+ author?: string,
84
+ /**
85
+ * SPDX license expression, at most 64 characters.
86
+ */
87
+ license?: string,
88
+ /**
89
+ * Project website.
90
+ */
91
+ homepage?: string,
92
+ /**
93
+ * Source repository.
94
+ */
95
+ repository?: string,
96
+ /**
97
+ * Where users report problems.
98
+ */
99
+ bugs?: string, };
79
100
  export type PackageInfo = { manifest: Manifest, digest: string, publisher: string,
80
101
  /**
81
102
  * Bytes of all signed files, i.e. the installed size. Records written
@@ -86,7 +107,12 @@ size: number,
86
107
  * What this build accepted but does not understand (`unknown_contribution:<key>`);
87
108
  * shown at install and in the developer log, never a reason to refuse.
88
109
  */
89
- warnings?: Array<string>, };
110
+ warnings?: Array<string>,
111
+ /**
112
+ * The package's signed `about.json` (author, license, links), when valid.
113
+ * Older Medeo ignore the file, so declaring it never blocks installation.
114
+ */
115
+ about?: About, };
90
116
  export type CapabilityDescriptor = { permission: string, group: string, version: number, scoped: boolean, methods: Array<string>, available_methods: Array<string>, granted: boolean, declared: boolean, os_authorization: string, };
91
117
  export type DirectoryGrant = { id: string, name: string, available: boolean,
92
118
  /**