@shenora/react 0.11.0 → 0.12.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/bridge.d.ts CHANGED
@@ -6,7 +6,7 @@ export interface ShenoraBridgeOptions {
6
6
  /**
7
7
  * The channel to the host. Default: whichever Shenora host this page is in — WebView2 postMessage
8
8
  * on the desktop shell, `HybridWebView` on the MAUI shell — else null (plain browser). Supply your
9
- * own for another shell (a WebSocket, D16) or a scripted fake for tests/preview harnesses.
9
+ * own for another shell, or a scripted fake for tests and preview harnesses.
10
10
  */
11
11
  transport?: ShenoraTransport | null;
12
12
  /** The event bus host notifications are unbundled into. Default: the shared bus. */
@@ -14,25 +14,23 @@ export interface ShenoraBridgeOptions {
14
14
  /** Per-request timeout in ms when the call doesn't set one. Family default: 30 000. */
15
15
  defaultTimeoutMs?: number;
16
16
  /**
17
- * Pure-UI development seam: answers requests when NO transport exists (plain browser tab —
18
- * component/layout work without the desktop host). Return the response data (or a promise;
19
- * throw to reject). Generalized from the source app's hardcoded dev mocks: the mocks are app
20
- * schema, so the app supplies them — gate with `import.meta.env.DEV` at the call site so
21
- * production stays hard-failing.
17
+ * Pure-UI development seam: answers requests when NO transport exists (a plain browser tab).
18
+ * Return the response data, or a promise; throw to reject.
19
+ *
20
+ * ⚠ Gate the call site with `import.meta.env.DEV` so production stays hard-failing.
22
21
  */
23
22
  fallback?: (request: IpcRequest) => unknown;
24
23
  /**
25
24
  * Where a FAILED {@link ShenoraBridge.post} is reported. Default: `console.error`.
26
25
  *
27
- * A one-way send has no promise to reject, so without this its failures would be invisible — and an
28
- * unmatched response is dropped silently by the inbound handler, which is exactly how a feature
29
- * "just stops working" with nothing to grep for. Route it into the app's logger/toast instead.
26
+ * ⚠ Route it into the app's logger or toast. A one-way send has no promise to reject, so its
27
+ * failures are otherwise invisible.
30
28
  */
31
29
  onPostError?: (error: PostFailure) => void;
32
30
  /**
33
31
  * How many unawaited {@link ShenoraBridge.post} ids to remember for error reporting. Default 256.
34
- * Capped (drop-oldest) so a host that never answers cannot grow the set without bound — the same
35
- * shape as the host's own bounded notification queue. Evicting an id only loses its error report.
32
+ * Capped drop-oldest, so a host that never answers cannot grow the set without bound; evicting an
33
+ * id only loses its error report.
36
34
  */
37
35
  maxTrackedPosts?: number;
38
36
  }
@@ -59,10 +57,10 @@ export interface PostOptions<TPayload = unknown> {
59
57
  scope?: string;
60
58
  }
61
59
  /**
62
- * The client side of the Shenora IPC contract, ported from the primary desktop sibling:
63
- * correlated request/response over a pluggable transport, category routing of host messages
64
- * (`ipc` → resolve the pending call, `notification` → unbundle the batch into the event bus),
65
- * per-request timeout, the ready handshake, and a browser fallback seam for pure-UI development.
60
+ * The client side of the Shenora IPC contract: correlated request/response over a pluggable
61
+ * transport, category routing of host messages (`ipc` → resolve the pending call, `notification` →
62
+ * unbundle the batch into the event bus), per-request timeout, the ready handshake, and a browser
63
+ * fallback seam for pure-UI development.
66
64
  *
67
65
  * Most apps use the lazy default instance via {@link getBridge}/{@link configureBridge}; create
68
66
  * instances directly for tests or multi-transport setups.
@@ -82,9 +80,7 @@ export declare class ShenoraBridge {
82
80
  constructor(options?: ShenoraBridgeOptions);
83
81
  /**
84
82
  * True when this bridge can actually send: a transport to a host exists AND the bridge has not been
85
- * disposed. It used to ignore `disposed`, so a stale reference to a bridge that `configureBridge`
86
- * replaced still reported itself available while every `invoke` on it rejected with `NO_TRANSPORT` —
87
- * the exact case the disposed check in `invoke` exists for (P5.5 H2).
83
+ * disposed. The check to make on a reference that may have outlived a {@link configureBridge} swap.
88
84
  */
89
85
  get isAvailable(): boolean;
90
86
  /**
@@ -96,28 +92,17 @@ export declare class ShenoraBridge {
96
92
  /**
97
93
  * Send WITHOUT awaiting a reply, and return the request id.
98
94
  *
99
- * This is the default shape for a desktop shell, and {@link invoke} is the special case — see
100
- * `docs/DECISIONS.md` D23. Two reasons: a correlated call carries a deadline
101
- * (30 s by default) and real work does not; and request/response is UI-THREAD-COUPLED here by
102
- * design, because the dispatch pipeline preserves the caller's synchronization context so facades
103
- * can touch the window. Reserve `invoke` for calls that are quick AND safe on the UI thread — the
104
- * window commands are the model — and post everything else, streaming results back as notifications.
105
- *
106
- * ⚠ Posting is only HALF of freeing the UI thread. The host still dispatches on the UI thread
107
- * whether or not the client awaits, so a handler that does heavy work synchronously stalls the
108
- * window either way. The other half is the host's: return from the route immediately and stream.
95
+ * This is the default shape for a desktop shell and {@link invoke} is the special case (D23):
96
+ * reserve `invoke` for calls that are quick AND safe on the host's UI thread, and post everything
97
+ * else, streaming results back as notifications.
109
98
  *
110
- * Failures are not silent. There is no promise to reject, so a failed response is reported through
111
- * `onPostError` (default `console.error`) rather than being dropped the way an unmatched response
112
- * otherwise is. ⚠ Reporting it means the id IS remembered — see {@link ShenoraBridgeOptions.maxTrackedPosts},
113
- * which caps the set drop-oldest so a host that never answers cannot grow it without bound. No TIMER is
114
- * set, though, so there is no deadline and nothing to fire later.
99
+ * A failed response is reported through `onPostError` (default `console.error`) rather than
100
+ * dropped. The id is remembered for that — see {@link ShenoraBridgeOptions.maxTrackedPosts}. No
101
+ * timer is set, so there is no deadline.
115
102
  *
116
- * No transport (a plain browser tab) is a silent no-op, matching the fire-and-forget contract —
117
- * unlike `invoke`, there is no caller waiting to be told. **A DISPOSED bridge is the same silent
118
- * no-op**, and that one can surprise: `invoke` on a disposed bridge rejects with `NO_TRANSPORT`
119
- * while `post` just returns the id, so a stale reference kept across a `configureBridge` swap looks
120
- * like it is still sending. `isAvailable` is the check.
103
+ * ⚠ No transport (a plain browser tab) is a silent no-op, and **so is a DISPOSED bridge** — where
104
+ * `invoke` would reject with `NO_TRANSPORT`, this just returns the id, so a stale reference kept
105
+ * across a {@link configureBridge} swap looks like it is still sending. `isAvailable` is the check.
121
106
  */
122
107
  post<TPayload = unknown>(module: string, type: string, options?: PostOptions<TPayload>): string;
123
108
  /**
@@ -127,22 +112,18 @@ export declare class ShenoraBridge {
127
112
  * also the host's cue to reset per-page state. No-transport is a silent no-op so browser dev
128
113
  * doesn't error.
129
114
  *
130
- * Ordering used to matter here and no longer does for drop zones: the host cleared the previous
131
- * page's overlays on this handshake, so a `REGISTER` sent before `READY` was wiped even though it
132
- * was acked. `DropZoneManager` now clears on DOCUMENT CHANGE instead, which cannot race the client.
133
- * The general point still stands for any per-page state YOUR host resets here — a reset keyed on
134
- * the handshake races anything the page sends before it, and in React that is structural rather
135
- * than a mistake, because CHILD effects run before PARENT effects.
115
+ * ⚠ Any per-page state YOUR host resets on this handshake races whatever the page sent before it,
116
+ * and in React that is structural rather than bad luck: CHILD effects run before PARENT effects.
136
117
  *
137
- * The returned promise REJECTS on a failed handshake (disposed bridge, timeout). Handle it —
118
+ * ⚠ The returned promise REJECTS on a failed handshake (disposed bridge, timeout). Handle it —
138
119
  * `void bridge.notifyReady()` turns that into an unhandled rejection, which in a WebView2 page is
139
120
  * a silent console error.
140
121
  */
141
122
  notifyReady<TPayload = unknown>(payload?: TPayload): Promise<ShellInfo | undefined>;
142
123
  /**
143
124
  * What the host said it was during {@link notifyReady} — undefined before the handshake, or when
144
- * the host advertised nothing. Cached so components can read it synchronously while rendering,
145
- * which is the whole point: a capability learned after layout is a flash.
125
+ * the host advertised nothing. Cached so components can read it synchronously while rendering; a
126
+ * capability learned after layout is a visible flash.
146
127
  */
147
128
  get shell(): ShellInfo | undefined;
148
129
  /** Reject everything in flight and detach from the transport. */
package/dist/bridge.js CHANGED
@@ -8,10 +8,10 @@ const newId = () => randomId();
8
8
  const isThenable = (value) => typeof value === 'object' && value !== null
9
9
  && typeof value.then === 'function';
10
10
  /**
11
- * The client side of the Shenora IPC contract, ported from the primary desktop sibling:
12
- * correlated request/response over a pluggable transport, category routing of host messages
13
- * (`ipc` → resolve the pending call, `notification` → unbundle the batch into the event bus),
14
- * per-request timeout, the ready handshake, and a browser fallback seam for pure-UI development.
11
+ * The client side of the Shenora IPC contract: correlated request/response over a pluggable
12
+ * transport, category routing of host messages (`ipc` → resolve the pending call, `notification` →
13
+ * unbundle the batch into the event bus), per-request timeout, the ready handshake, and a browser
14
+ * fallback seam for pure-UI development.
15
15
  *
16
16
  * Most apps use the lazy default instance via {@link getBridge}/{@link configureBridge}; create
17
17
  * instances directly for tests or multi-transport setups.
@@ -20,7 +20,7 @@ export class ShenoraBridge {
20
20
  constructor(options = {}) {
21
21
  this.pending = new Map();
22
22
  // Ids of one-way sends, kept ONLY so a failed response can be reported instead of vanishing.
23
- // Insertion-ordered and capped: a Map is used for its ordered keys, not for the values.
23
+ // A Map for its insertion-ordered keys, so the cap can evict the oldest.
24
24
  this.unawaited = new Map();
25
25
  this.disposed = false;
26
26
  this.transport = options.transport !== undefined ? options.transport : createHostTransport();
@@ -34,9 +34,7 @@ export class ShenoraBridge {
34
34
  }
35
35
  /**
36
36
  * True when this bridge can actually send: a transport to a host exists AND the bridge has not been
37
- * disposed. It used to ignore `disposed`, so a stale reference to a bridge that `configureBridge`
38
- * replaced still reported itself available while every `invoke` on it rejected with `NO_TRANSPORT` —
39
- * the exact case the disposed check in `invoke` exists for (P5.5 H2).
37
+ * disposed. The check to make on a reference that may have outlived a {@link configureBridge} swap.
40
38
  */
41
39
  get isAvailable() {
42
40
  return !this.disposed && this.transport !== null;
@@ -48,9 +46,8 @@ export class ShenoraBridge {
48
46
  */
49
47
  invoke(module, type, options = {}) {
50
48
  if (this.disposed) {
51
- // Fail fast: the transport subscription is gone, so a response could never correlate —
52
- // without this the call would burn the full timeout (stale references after
53
- // configureBridge replaced the default are the typical way here).
49
+ // Fail fast: the transport subscription is gone, so a response could never correlate and the
50
+ // call would otherwise burn the full timeout.
54
51
  return Promise.reject(new ShenoraError({
55
52
  code: IpcErrorCodes.noTransport,
56
53
  message: `Bridge disposed — ${module}.${type} cannot be sent.`,
@@ -74,18 +71,12 @@ export class ShenoraBridge {
74
71
  catch (error) {
75
72
  return Promise.reject(error);
76
73
  }
77
- // A fallback may be async (a scripted preview harness commonly is), and this path used to
78
- // bypass the timeout entirely — so a fallback that never settled hung the caller forever, with
79
- // none of the diagnostics the real path gives (P5.5 H2). Only a thenable needs racing; a plain
80
- // value is already settled.
74
+ // An async fallback is raced against the timeout too, or one that never settles hangs the
75
+ // caller forever. Only a thenable needs racing; a plain value has already settled.
81
76
  if (!isThenable(result))
82
77
  return Promise.resolve(result);
83
- // ⚠ The loser's timer must be CLEARED, as every other path in this file does — the real invoke's
84
- // timeout handler, its transport-throw catch, its response path and `dispose`. Leaving it holds a
85
- // live timer per call for the full timeout
86
- // (30 s by default), each holding its closure. Harmless to a caller, because `race` has already
87
- // settled and has a rejection handler attached either way, but it is the same "one site out of
88
- // N" shape the rest of this file is careful about, and it keeps timers pending in a test run.
78
+ // ⚠ The loser's timer is cleared in `finally` — otherwise every call holds a live timer, and
79
+ // its closure, for the full timeout.
89
80
  let timer;
90
81
  return Promise.race([
91
82
  Promise.resolve(result),
@@ -130,28 +121,17 @@ export class ShenoraBridge {
130
121
  /**
131
122
  * Send WITHOUT awaiting a reply, and return the request id.
132
123
  *
133
- * This is the default shape for a desktop shell, and {@link invoke} is the special case — see
134
- * `docs/DECISIONS.md` D23. Two reasons: a correlated call carries a deadline
135
- * (30 s by default) and real work does not; and request/response is UI-THREAD-COUPLED here by
136
- * design, because the dispatch pipeline preserves the caller's synchronization context so facades
137
- * can touch the window. Reserve `invoke` for calls that are quick AND safe on the UI thread — the
138
- * window commands are the model — and post everything else, streaming results back as notifications.
124
+ * This is the default shape for a desktop shell and {@link invoke} is the special case (D23):
125
+ * reserve `invoke` for calls that are quick AND safe on the host's UI thread, and post everything
126
+ * else, streaming results back as notifications.
139
127
  *
140
- * ⚠ Posting is only HALF of freeing the UI thread. The host still dispatches on the UI thread
141
- * whether or not the client awaits, so a handler that does heavy work synchronously stalls the
142
- * window either way. The other half is the host's: return from the route immediately and stream.
128
+ * A failed response is reported through `onPostError` (default `console.error`) rather than
129
+ * dropped. The id is remembered for that — see {@link ShenoraBridgeOptions.maxTrackedPosts}. No
130
+ * timer is set, so there is no deadline.
143
131
  *
144
- * Failures are not silent. There is no promise to reject, so a failed response is reported through
145
- * `onPostError` (default `console.error`) rather than being dropped the way an unmatched response
146
- * otherwise is. ⚠ Reporting it means the id IS remembered — see {@link ShenoraBridgeOptions.maxTrackedPosts},
147
- * which caps the set drop-oldest so a host that never answers cannot grow it without bound. No TIMER is
148
- * set, though, so there is no deadline and nothing to fire later.
149
- *
150
- * No transport (a plain browser tab) is a silent no-op, matching the fire-and-forget contract —
151
- * unlike `invoke`, there is no caller waiting to be told. **A DISPOSED bridge is the same silent
152
- * no-op**, and that one can surprise: `invoke` on a disposed bridge rejects with `NO_TRANSPORT`
153
- * while `post` just returns the id, so a stale reference kept across a `configureBridge` swap looks
154
- * like it is still sending. `isAvailable` is the check.
132
+ * ⚠ No transport (a plain browser tab) is a silent no-op, and **so is a DISPOSED bridge** — where
133
+ * `invoke` would reject with `NO_TRANSPORT`, this just returns the id, so a stale reference kept
134
+ * across a {@link configureBridge} swap looks like it is still sending. `isAvailable` is the check.
155
135
  */
156
136
  post(module, type, options = {}) {
157
137
  const request = {
@@ -164,8 +144,7 @@ export class ShenoraBridge {
164
144
  };
165
145
  if (this.disposed || !this.transport)
166
146
  return request.id;
167
- // Remember it ONLY to report a failure. Drop-oldest at the cap so a host that never answers
168
- // cannot grow this without bound; an evicted id simply loses its error report.
147
+ // Remembered ONLY to report a failure, drop-oldest at the cap.
169
148
  if (this.unawaited.size >= this.maxTrackedPosts) {
170
149
  const oldest = this.unawaited.keys().next();
171
150
  if (!oldest.done)
@@ -196,14 +175,10 @@ export class ShenoraBridge {
196
175
  * also the host's cue to reset per-page state. No-transport is a silent no-op so browser dev
197
176
  * doesn't error.
198
177
  *
199
- * Ordering used to matter here and no longer does for drop zones: the host cleared the previous
200
- * page's overlays on this handshake, so a `REGISTER` sent before `READY` was wiped even though it
201
- * was acked. `DropZoneManager` now clears on DOCUMENT CHANGE instead, which cannot race the client.
202
- * The general point still stands for any per-page state YOUR host resets here — a reset keyed on
203
- * the handshake races anything the page sends before it, and in React that is structural rather
204
- * than a mistake, because CHILD effects run before PARENT effects.
178
+ * ⚠ Any per-page state YOUR host resets on this handshake races whatever the page sent before it,
179
+ * and in React that is structural rather than bad luck: CHILD effects run before PARENT effects.
205
180
  *
206
- * The returned promise REJECTS on a failed handshake (disposed bridge, timeout). Handle it —
181
+ * ⚠ The returned promise REJECTS on a failed handshake (disposed bridge, timeout). Handle it —
207
182
  * `void bridge.notifyReady()` turns that into an unhandled rejection, which in a WebView2 page is
208
183
  * a silent console error.
209
184
  */
@@ -211,14 +186,9 @@ export class ShenoraBridge {
211
186
  if (!this.transport)
212
187
  return undefined;
213
188
  // The host answers with what it IS and what it can do, so a page can render one tree on every
214
- // shell instead of sniffing the platform. A host that says nothing (no descriptor configured, or
215
- // one predating this) leaves it UNDEFINED — absent means "assume nothing", never "assume
216
- // desktop".
217
- //
218
- // undefined rather than null on purpose: JSON null means absent on this wire and the client
219
- // convention is undefined (see .claude/knowledge/ipc-contracts.md). Returning null broke two
220
- // existing tests that assert this resolves to undefined, which is the convention catching a
221
- // deviation exactly where it should.
189
+ // shell instead of sniffing the platform. A host that says nothing leaves it UNDEFINED (never
190
+ // null — JSON null means absent on this wire) — and absent means "assume nothing", never
191
+ // "assume desktop".
222
192
  const shell = await this.invoke(HANDSHAKE_MODULE, HANDSHAKE_TYPE, { payload });
223
193
  this.shellInfo = shell && typeof shell === 'object' && typeof shell.name === 'string'
224
194
  ? { name: shell.name, capabilities: Array.isArray(shell.capabilities) ? shell.capabilities : [] }
@@ -227,8 +197,8 @@ export class ShenoraBridge {
227
197
  }
228
198
  /**
229
199
  * What the host said it was during {@link notifyReady} — undefined before the handshake, or when
230
- * the host advertised nothing. Cached so components can read it synchronously while rendering,
231
- * which is the whole point: a capability learned after layout is a flash.
200
+ * the host advertised nothing. Cached so components can read it synchronously while rendering; a
201
+ * capability learned after layout is a visible flash.
232
202
  */
233
203
  get shell() {
234
204
  return this.shellInfo;
@@ -244,8 +214,7 @@ export class ShenoraBridge {
244
214
  entry.reject(new ShenoraError({ code: IpcErrorCodes.noTransport, message: 'Bridge disposed.' }));
245
215
  this.pending.delete(id);
246
216
  }
247
- // Unawaited ids are pure bookkeeping with nothing to settle — drop them so a disposed bridge
248
- // holds no references (this is the instance `configureBridge` replaces).
217
+ // Pure bookkeeping with nothing to settle — dropped so a disposed bridge holds no references.
249
218
  this.unawaited.clear();
250
219
  }
251
220
  onHostMessage(message) {
@@ -257,10 +226,9 @@ export class ShenoraBridge {
257
226
  console.error('[shenora] ignored unparseable host message:', error);
258
227
  return;
259
228
  }
260
- // A literal `null` is VALID JSON, so it survives the parse and then `parsed.category` threw a
261
- // TypeError — out of a transport listener, i.e. an uncaught page error with no caller to catch it
262
- // (P5.5 H2). Primitives (`"str"`, `123`, `true`) never threw because property access on them just
263
- // yields undefined; null and only null did. Anything that isn't an object simply isn't ours.
229
+ // ⚠ A literal `null` is VALID JSON and survives the parse, and property access on it throws —
230
+ // out of a transport listener, i.e. an uncaught page error with no caller to catch it. Anything
231
+ // that is not an object simply is not ours.
264
232
  if (parsed === null || typeof parsed !== 'object')
265
233
  return;
266
234
  const envelope = parsed;
@@ -270,10 +238,9 @@ export class ShenoraBridge {
270
238
  return;
271
239
  const entry = this.pending.get(response.id);
272
240
  if (!entry) {
273
- // No pending call. Either this answers a one-way `post` — in which case a FAILURE must be
274
- // surfaced, because there is no promise to reject and dropping it here is exactly how a
275
- // feature "just stops working" with nothing to grep for — or it is a late/foreign response,
276
- // which stays ignored.
241
+ // No pending call. Either this answers a one-way `post`, whose FAILURE must be surfaced
242
+ // because there is no promise to reject, or it is a late/foreign response, which stays
243
+ // ignored.
277
244
  const posted = this.unawaited.get(response.id);
278
245
  if (posted) {
279
246
  this.unawaited.delete(response.id);
@@ -5,11 +5,9 @@ export declare const PNG_IMAGE = "image/png";
5
5
  /** Media type for UTF-8 HTML, for a paste that keeps its formatting. */
6
6
  export declare const HTML = "text/html";
7
7
  /**
8
- * One clipboard item and every representation it offers.
9
- *
10
- * `text` and `files` are named because every platform has a first-class API for them; everything else
11
- * lives in `formats`, keyed by media type — {@link PNG_IMAGE}, {@link HTML}, or your own
12
- * `application/…` type, which the host carries verbatim so a paste can round-trip it losslessly.
8
+ * One clipboard item and every representation it offers. `text` and `files` are named because every
9
+ * platform has a first-class API for them; everything else lives in `formats`, keyed by media type —
10
+ * {@link PNG_IMAGE}, {@link HTML}, or your own `application/…` type, which the host carries verbatim.
13
11
  */
14
12
  export interface ClipboardContent {
15
13
  /** The plain-text representation. */
@@ -45,7 +43,7 @@ export declare class ClipboardAccess extends BaseModuleService<ClipboardRequests
45
43
  constructor(bridge?: ShenoraBridge);
46
44
  /**
47
45
  * Everything the clipboard is offering — **no user gesture, no permission prompt, no focus
48
- * requirement**, which is the half `navigator.clipboard.read()` cannot give you.
46
+ * requirement**, unlike `navigator.clipboard.read()`.
49
47
  */
50
48
  read(): Promise<ClipboardContent>;
51
49
  /**
@@ -62,10 +60,7 @@ export declare class ClipboardAccess extends BaseModuleService<ClipboardRequests
62
60
  export interface ClipboardHandle {
63
61
  /** The typed client. Stable across renders. */
64
62
  clipboard: ClipboardAccess;
65
- /**
66
- * This shell can put a FILE LIST on the clipboard — desktop only. Decide what to RENDER with it; a
67
- * refused call rejects, which is the honest answer to a question that should not have been asked.
68
- */
63
+ /** This shell can put a FILE LIST on the clipboard — desktop only. Use it to decide what to RENDER. */
69
64
  canCopyFiles: boolean;
70
65
  }
71
66
  /**
package/dist/clipboard.js CHANGED
@@ -1,23 +1,18 @@
1
1
  /**
2
2
  * The page's half of the host's `SHENORA.CLIPBOARD` module — the parts of the native clipboard the web
3
- * platform withholds.
3
+ * platform withholds. Mirrors `Shenora.Modules.Clipboard.ClipboardModule`, pinned by `WireMirrorTests`.
4
4
  *
5
- * 🔴 **Reach for `navigator.clipboard` first.** The page runs in a real browser: a gesture-driven "copy
6
- * this text" or "copy this image" already works, needs no host round trip, and is what the platform
7
- * expects. Two things it cannot do are why this module exists —
5
+ * 🔴 **Reach for `navigator.clipboard` first.** A gesture-driven "copy this text" or "copy this image"
6
+ * already works and needs no host round trip. Two things it cannot do are why this module exists —
8
7
  *
9
8
  * 1. **Files.** No web API puts a file list on the clipboard, so the user cannot paste into Explorer,
10
9
  * Finder or a file manager. There is no polyfill.
11
10
  * 2. **Access with no user gesture or focus.** `navigator.clipboard.read()` needs transient activation,
12
11
  * document focus and a permission. A host has none of those constraints.
13
12
  *
14
- * ⚠ **And the choice is per-COPY, not per-format**, because a clipboard set is atomic: one item, last
15
- * writer wins outright. An item that includes files must be written entirely through
16
- * {@link ClipboardAccess.write}
17
- * — writing its text half with `navigator.clipboard` and the files here leaves only the files, silently.
18
- *
19
- * Mirrors `Shenora.Modules.Clipboard.ClipboardModule`. The wire names are pinned against the host's own
20
- * constants by `WireMirrorTests`, not by care.
13
+ * ⚠ **The choice is per-COPY, not per-format**: a clipboard set is atomic, one item, last writer wins.
14
+ * An item that includes files must be written entirely through {@link ClipboardAccess.write} — writing
15
+ * its text half with `navigator.clipboard` and the files here leaves only the files, silently.
21
16
  */
22
17
  import { useMemo } from 'react';
23
18
  import { useShellInfo } from './hooks.js';
@@ -28,11 +23,10 @@ export const PNG_IMAGE = 'image/png';
28
23
  /** Media type for UTF-8 HTML, for a paste that keeps its formatting. */
29
24
  export const HTML = 'text/html';
30
25
  /**
31
- * Base64 without a `Buffer` and without `btoa(String.fromCharCode(...bytes))`.
26
+ * Base64 without a `Buffer`.
32
27
  *
33
- * ⚠ The spread form is the idiom everyone writes and it throws `RangeError` on a large image — the
34
- * argument list overflows the call stack somewhere north of ~100 kB, which is a small screenshot. A
35
- * chunked loop has no such ceiling.
28
+ * ⚠ Never `btoa(String.fromCharCode(...bytes))`: the spread overflows the argument list and throws
29
+ * `RangeError` on anything past roughly a small screenshot. A chunked loop has no such ceiling.
36
30
  */
37
31
  function toBase64(bytes) {
38
32
  let binary = '';
@@ -61,7 +55,7 @@ export class ClipboardAccess extends BaseModuleService {
61
55
  }
62
56
  /**
63
57
  * Everything the clipboard is offering — **no user gesture, no permission prompt, no focus
64
- * requirement**, which is the half `navigator.clipboard.read()` cannot give you.
58
+ * requirement**, unlike `navigator.clipboard.read()`.
65
59
  */
66
60
  async read() {
67
61
  const wire = await this.send('READ');
@@ -115,8 +109,7 @@ export class ClipboardAccess extends BaseModuleService {
115
109
  */
116
110
  export function useClipboard(clipboard) {
117
111
  const shell = useShellInfo();
118
- // The service is stateless and holds only its module name, but a fresh instance per render would
119
- // still make it useless as an effect dependency — the same reason useFileDialogs caches one.
112
+ // Cached so it is usable as an effect dependency, not because the service is expensive.
120
113
  const client = useMemo(() => clipboard ?? new ClipboardAccess(), [clipboard]);
121
114
  const capabilities = shell?.capabilities;
122
115
  return useMemo(() => ({
@@ -1,11 +1,10 @@
1
1
  /**
2
- * Dev-only IPC + event-hub interceptor, ported from the primary desktop sibling (NEVER ship it
3
- * in prod — gate the single call site with `import.meta.env.DEV`).
2
+ * Dev-only IPC + event-hub interceptor. ⚠ NEVER ship it in prod — gate the single call site with
3
+ * `import.meta.env.DEV`.
4
4
  *
5
- * Why: during desktop-app testing the agent drives the UI over CDP, but native dialogs and
6
- * event-driven flows can't be exercised by clicking. This wraps the bridge's `invoke` (the IPC
7
- * seam) and the event bus's `emit` (the event hub) to (1) record + console.debug every
8
- * request/response/event into ring buffers, and (2) expose a window global so a CDP eval can
5
+ * For driving a page over CDP, where native dialogs and event-driven flows cannot be exercised by
6
+ * clicking. It wraps the bridge's `invoke` and the event bus's `emit` to (1) record + console.debug
7
+ * every request/response/event into ring buffers, and (2) expose a window global so a CDP eval can
9
8
  * invoke ANY IPC directly and await events:
10
9
  *
11
10
  * window.__shenora.call('NOTES', 'ADD', { title: 'x' }) // drive an IPC, bypass the UI
@@ -47,11 +46,8 @@ export interface DevInterceptorOptions {
47
46
  * Install the interceptor. Idempotent across HMR and StrictMode's double-invoke.
48
47
  *
49
48
  * 🔴 **Idempotency is keyed on WHICH bridge and bus were wrapped, not on "something is installed".**
50
- * The wrapping mutates a specific `ShenoraBridge` INSTANCE, but the guard used to ask only whether the
51
- * window global existed — so `configureBridge()`, which disposes the default bridge and builds a new
52
- * one, left the interceptor pointing at the dead instance and a second install returning early without
53
- * wrapping the live one. The tool then looked installed and recorded NOTHING, while
54
- * `window.__shenora.call` drove a disposed bridge. A dev tool that fails silently is worse than one that
55
- * is absent, because its silence reads as "no traffic".
49
+ * The wrapping mutates a specific `ShenoraBridge` INSTANCE, so a guard that only asks whether the
50
+ * window global exists leaves the interceptor pointing at the bridge `configureBridge()` disposed:
51
+ * installed-looking, recording NOTHING, and its silence reads as "no traffic".
56
52
  */
57
53
  export declare function installDevInterceptor(options?: DevInterceptorOptions): void;
@@ -1,11 +1,10 @@
1
1
  /**
2
- * Dev-only IPC + event-hub interceptor, ported from the primary desktop sibling (NEVER ship it
3
- * in prod — gate the single call site with `import.meta.env.DEV`).
2
+ * Dev-only IPC + event-hub interceptor. ⚠ NEVER ship it in prod — gate the single call site with
3
+ * `import.meta.env.DEV`.
4
4
  *
5
- * Why: during desktop-app testing the agent drives the UI over CDP, but native dialogs and
6
- * event-driven flows can't be exercised by clicking. This wraps the bridge's `invoke` (the IPC
7
- * seam) and the event bus's `emit` (the event hub) to (1) record + console.debug every
8
- * request/response/event into ring buffers, and (2) expose a window global so a CDP eval can
5
+ * For driving a page over CDP, where native dialogs and event-driven flows cannot be exercised by
6
+ * clicking. It wraps the bridge's `invoke` and the event bus's `emit` to (1) record + console.debug
7
+ * every request/response/event into ring buffers, and (2) expose a window global so a CDP eval can
9
8
  * invoke ANY IPC directly and await events:
10
9
  *
11
10
  * window.__shenora.call('NOTES', 'ADD', { title: 'x' }) // drive an IPC, bypass the UI
@@ -18,12 +17,9 @@ import { eventBus as defaultEventBus } from './eventBus.js';
18
17
  * Install the interceptor. Idempotent across HMR and StrictMode's double-invoke.
19
18
  *
20
19
  * 🔴 **Idempotency is keyed on WHICH bridge and bus were wrapped, not on "something is installed".**
21
- * The wrapping mutates a specific `ShenoraBridge` INSTANCE, but the guard used to ask only whether the
22
- * window global existed — so `configureBridge()`, which disposes the default bridge and builds a new
23
- * one, left the interceptor pointing at the dead instance and a second install returning early without
24
- * wrapping the live one. The tool then looked installed and recorded NOTHING, while
25
- * `window.__shenora.call` drove a disposed bridge. A dev tool that fails silently is worse than one that
26
- * is absent, because its silence reads as "no traffic".
20
+ * The wrapping mutates a specific `ShenoraBridge` INSTANCE, so a guard that only asks whether the
21
+ * window global exists leaves the interceptor pointing at the bridge `configureBridge()` disposed:
22
+ * installed-looking, recording NOTHING, and its silence reads as "no traffic".
27
23
  */
28
24
  export function installDevInterceptor(options = {}) {
29
25
  if (typeof window === 'undefined')
@@ -31,11 +27,10 @@ export function installDevInterceptor(options = {}) {
31
27
  const globalName = options.globalName ?? '__shenora';
32
28
  const w = window;
33
29
  const ringSize = options.ringSize ?? 300;
34
- // Resolved BEFORE the guard, because the guard's question is now about these two objects.
30
+ // Resolved BEFORE the guard, because the guard's question is about these two objects.
35
31
  const bridge = options.bridge ?? getBridge();
36
32
  const bus = options.bus ?? defaultEventBus;
37
- // Same pair = the HMR/StrictMode case this exists for; wrapping twice would double every log line and
38
- // stack a second recorder on the first.
33
+ // Same pair = the HMR/StrictMode case; wrapping twice doubles every log line.
39
34
  const installed = w[globalName];
40
35
  if (installed && installed.bridge === bridge && installed.eventBus === bus)
41
36
  return;
package/dist/errors.d.ts CHANGED
@@ -1,10 +1,9 @@
1
1
  import type { IpcError } from './types.js';
2
2
  /**
3
- * The rejection type for every failed bridge call — the client mirror of the host's
4
- * `ShenoraException` (the `Error` suffix is the TS idiom; the C# type keeps the platform's
5
- * `Exception` suffix). Carries the structured code + parameters so callers translate
6
- * `errors.{code}` instead of matching message strings. Client-side failures (timeout, missing
7
- * transport) reject through this same shape with the client-reserved codes.
3
+ * The rejection type for every failed bridge call — the client mirror of the host's `ShenoraException`.
4
+ * Carries the structured code + parameters so callers translate `errors.{code}` instead of matching
5
+ * message strings. Client-side failures (timeout, missing transport) reject through this same shape
6
+ * with the client-reserved codes.
8
7
  */
9
8
  export declare class ShenoraError extends Error {
10
9
  /** Error code / i18n key (e.g. `"IMPORT_FAILED"`, `"TIMEOUT"`). */
package/dist/errors.js CHANGED
@@ -1,9 +1,8 @@
1
1
  /**
2
- * The rejection type for every failed bridge call — the client mirror of the host's
3
- * `ShenoraException` (the `Error` suffix is the TS idiom; the C# type keeps the platform's
4
- * `Exception` suffix). Carries the structured code + parameters so callers translate
5
- * `errors.{code}` instead of matching message strings. Client-side failures (timeout, missing
6
- * transport) reject through this same shape with the client-reserved codes.
2
+ * The rejection type for every failed bridge call — the client mirror of the host's `ShenoraException`.
3
+ * Carries the structured code + parameters so callers translate `errors.{code}` instead of matching
4
+ * message strings. Client-side failures (timeout, missing transport) reject through this same shape
5
+ * with the client-reserved codes.
7
6
  */
8
7
  export class ShenoraError extends Error {
9
8
  constructor(error) {