@shenora/react 0.10.0 → 0.11.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/store.d.ts CHANGED
@@ -13,12 +13,16 @@ export interface ShenoraStoreIo<TState = unknown> {
13
13
  /** Current state — for an action that needs to compute an OPTIMISTIC update from it. */
14
14
  getState: () => TState;
15
15
  /**
16
- * Apply a local state change with NO host round trip and NO wire event — the seam for an
17
- * optimistic update an action can fully decide by itself (e.g. dropping the rows a
18
- * `CLEAR_FINISHED`-style action just told the host to drop). The host stays authoritative for
19
- * everything a `snapshot`/`on` reducer already covers; this exists for the narrow case where the
20
- * action already knows the answer and a host round trip would only be a delivery delay, not new
21
- * information.
16
+ * Apply a local state change with NO host round trip and NO wire event — an optimistic update an
17
+ * action can fully decide by itself. The host stays authoritative for everything a `snapshot`/`on`
18
+ * reducer covers; this is for state the ACTION already knows and the host would only echo back.
19
+ *
20
+ * 🔴 **Reach for it only when the host has no event to tell you.** This used to be documented with
21
+ * the kit's own `clearFinished` doing an optimistic prune, and that example was WITHDRAWN: the
22
+ * moment `REQUEST_REMOVED` existed, the local prune became a second thing deciding which rows are
23
+ * gone, able to disagree with the host about it. If the host emits an event for the change, let the
24
+ * reducer own it — an optimistic path beside a wire event is a divergence waiting to happen, not a
25
+ * latency win.
22
26
  */
23
27
  setState: (reduce: (state: TState) => TState) => void;
24
28
  }
@@ -30,7 +34,6 @@ export interface ShenoraStoreSnapshot<TState> {
30
34
  /** Fold the response into state. */
31
35
  apply: (state: TState, data: unknown) => TState;
32
36
  }
33
- /** Inputs for {@link createShenoraStore}. */
34
37
  export interface ShenoraStoreOptions<TState, TActions> {
35
38
  /** State before anything has arrived. */
36
39
  initial: TState;
package/dist/store.js CHANGED
@@ -1,6 +1,32 @@
1
1
  import { useCallback, useDebugValue, useRef, useSyncExternalStore } from 'react';
2
2
  import { getBridge } from './bridge.js';
3
3
  import { eventBus as defaultEventBus } from './eventBus.js';
4
+ /** Inputs for {@link createShenoraStore}. */
5
+ /**
6
+ * Is the fresh selector result the same VALUE as the previous one, for re-render purposes?
7
+ *
8
+ * `Object.is` plus ONE level of own-key comparison. The shallow step is what lets an inline selector
9
+ * that derives a new object work — `s => ({ count: s.lines.length })` builds a different object every
10
+ * call, and without this every call would look like a change and loop.
11
+ *
12
+ * ⚠ Deliberately one level. Deep equality would make an unchanged NESTED value hide a changed outer
13
+ * one only by cost, and a store whose selectors need deep comparison is selecting too much.
14
+ */
15
+ function equivalent(a, b) {
16
+ if (Object.is(a, b))
17
+ return true;
18
+ if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null)
19
+ return false;
20
+ // Arrays and plain objects only — anything exotic (Map, Date, a class) falls back to identity above.
21
+ if (Array.isArray(a) !== Array.isArray(b))
22
+ return false;
23
+ const aKeys = Object.keys(a);
24
+ const bKeys = Object.keys(b);
25
+ if (aKeys.length !== bKeys.length)
26
+ return false;
27
+ return aKeys.every((k) => Object.prototype.hasOwnProperty.call(b, k)
28
+ && Object.is(a[k], b[k]));
29
+ }
4
30
  /**
5
31
  * A store fed by one module's host event stream, shared by every component that reads it.
6
32
  *
@@ -46,6 +72,10 @@ export function createShenoraStore(module, options) {
46
72
  ?? ((error, context) => console.error(`[shenora] store ${context.module}.${context.type} failed:`, error));
47
73
  let state = initial;
48
74
  let snapshotLoaded = false;
75
+ // Bumped on detach. A snapshot response from an earlier subscriber epoch resolving late must be
76
+ // DROPPED: the current epoch has (or will have) its own, fresher request, and applying the old
77
+ // body after the new one is a lost update wearing a success path.
78
+ let snapshotEpoch = 0;
49
79
  const listeners = new Set();
50
80
  let unsubscribes = [];
51
81
  const bridge = () => options.bridge ?? getBridge();
@@ -66,7 +96,7 @@ export function createShenoraStore(module, options) {
66
96
  }
67
97
  catch (error) {
68
98
  // A throwing reducer must not corrupt shared state or break the other subscribers — the same
69
- // guarded-callback rule the host applies to app code (Shenora.Core.AppCallback).
99
+ // guarded-callback rule the host applies to app code (Shenora.AppCallback).
70
100
  report(error, { module, type });
71
101
  }
72
102
  };
@@ -75,9 +105,12 @@ export function createShenoraStore(module, options) {
75
105
  return;
76
106
  snapshotLoaded = true; // set BEFORE awaiting: two components mounting in the same tick must not
77
107
  // both fire the request (React StrictMode double-invokes effects, which is precisely this case).
108
+ const epoch = snapshotEpoch;
78
109
  bridge()
79
110
  .invoke(module, snapshot.type, { payload: snapshot.payload, scope })
80
111
  .then((data) => {
112
+ if (epoch !== snapshotEpoch)
113
+ return; // a previous epoch's answer — the live one owns state
81
114
  try {
82
115
  setState(snapshot.apply(state, data));
83
116
  }
@@ -85,6 +118,8 @@ export function createShenoraStore(module, options) {
85
118
  report(error, { module, type: snapshot.type });
86
119
  }
87
120
  }, (error) => {
121
+ if (epoch !== snapshotEpoch)
122
+ return;
88
123
  // Allow a later retry: a snapshot that failed because the host was not ready yet should not
89
124
  // leave the store permanently empty for the rest of the session.
90
125
  snapshotLoaded = false;
@@ -99,6 +134,13 @@ export function createShenoraStore(module, options) {
99
134
  for (const off of unsubscribes)
100
135
  off();
101
136
  unsubscribes = [];
137
+ // The next mount must RE-LOAD: with no bus subscription live, everything the host emits from
138
+ // here on is missed, so the snapshot the flag was guarding is stale the moment this returns.
139
+ // The flag exists to dedupe same-tick double-mounts (StrictMode), not to span subscriber epochs —
140
+ // and the epoch bump orphans any request still in flight, so its late answer cannot clobber the
141
+ // next epoch's fresher one.
142
+ snapshotLoaded = false;
143
+ snapshotEpoch++;
102
144
  };
103
145
  const subscribe = (listener) => {
104
146
  // ONE subscription per event type for the whole store — the property that makes this worth
@@ -119,23 +161,42 @@ export function createShenoraStore(module, options) {
119
161
  getState,
120
162
  setState: (reduce) => setState(reduce(getState())),
121
163
  };
164
+ /**
165
+ * Subscribe to the store, optionally through a selector.
166
+ *
167
+ * 🔴 **The selector is RE-RUN every time and the previous RESULT is reused when equivalent.** It used
168
+ * to be memoized against STATE identity alone, which looked like the careful thing and was wrong: a
169
+ * selector whose closure changed while the state did not returned the PREVIOUS selector's value. A
170
+ * list row doing `useShenoraRequests(s => s.byId[id])` whose `id` prop changes — virtualised reuse, a
171
+ * route change — rendered the previous row's data until some unrelated event replaced the state.
172
+ *
173
+ * ⚠ <b>Two requirements pull against each other and both are met by comparing RESULTS rather than
174
+ * inputs.</b> `useSyncExternalStore` re-renders whenever `getSnapshot` returns something new by
175
+ * `Object.is`, so:
176
+ * <list type="bullet">
177
+ * <item>keying the cache on STATE alone gives stale values when the selector changes — the bug above;</item>
178
+ * <item>keying it on the SELECTOR too loops forever for an inline selector that derives a new object
179
+ * (`s => ({ count: s.lines.length })`) — a fresh identity each render, a fresh object each call.
180
+ * zustand v5 has exactly that behaviour and answers it with an opt-in `useShallow`.</item>
181
+ * </list>
182
+ * Comparing the RESULT shallowly gives both: a derived object that is field-for-field the same reuses
183
+ * the previous reference and does not re-render, while a genuinely different row does.
184
+ */
122
185
  function useStore(selector) {
123
- // getSnapshot must return a STABLE value for an unchanged store, or React throws
124
- // "The result of getSnapshot should be cached" and can loop. So the selector result is memoized
125
- // against the state identity: recomputed only when state actually changed.
126
- const cache = useRef(null);
127
- const selectorRef = useRef(selector);
128
- selectorRef.current = selector;
186
+ // The last result handed to React. Reused whenever the fresh one is equivalent, which is what keeps
187
+ // getSnapshot stable without pinning it to an input that can go stale.
188
+ const previous = useRef(null);
129
189
  const getSelected = useCallback(() => {
130
190
  const current = getState();
131
- const select = selectorRef.current;
132
- if (!select)
191
+ if (!selector)
133
192
  return current;
134
- if (cache.current === null || !Object.is(cache.current.state, current)) {
135
- cache.current = { state: current, selected: select(current) };
193
+ const next = selector(current);
194
+ if (previous.current !== null && equivalent(previous.current.value, next)) {
195
+ return previous.current.value;
136
196
  }
137
- return cache.current.selected;
138
- }, []);
197
+ previous.current = { value: next };
198
+ return next;
199
+ }, [selector]);
139
200
  const value = useSyncExternalStore(subscribe, getSelected, getSelected);
140
201
  useDebugValue(value);
141
202
  return value;
package/dist/types.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The Shenora IPC wire contract — the TS mirror of the `Shenora.Ipc` C# envelopes (names are
2
+ * The Shenora IPC wire contract — the TS mirror of the `Shenora.Core.Ipc` C# envelopes (names are
3
3
  * pinned on both sides; the host serializes camelCase). Transport-neutral: the same envelopes
4
4
  * travel over WebView2 postMessage, a WebSocket, or a mobile shell's channel.
5
5
  */
@@ -21,7 +21,23 @@ export declare const HANDSHAKE_TYPE = "READY";
21
21
  */
22
22
  export declare const IpcErrorCodes: {
23
23
  readonly unknownError: "UNKNOWN_ERROR";
24
+ /**
25
+ * **No MODULE claimed the request** — nothing on the host answers that name. Parameters:
26
+ * `module`, `type`.
27
+ *
28
+ * ⚠ Distinct from {@link noRoute}, and the split is what makes it actionable: this means the module
29
+ * was never registered host-side, while `noRoute` means it WAS and does not know that type. Opposite
30
+ * fixes — wire the module up, versus correct a route name — so collapsing both into `NO_HANDLER` with
31
+ * identical parameters leaves a dead page undiagnosable from the wire.
32
+ */
24
33
  readonly noHandler: "NO_HANDLER";
34
+ /**
35
+ * **The module answered but has no route of that type.** Parameters: `module`, `type`.
36
+ *
37
+ * Seeing this is proof the module IS registered and mapped, which is exactly what {@link noHandler}
38
+ * cannot tell you — so this is a route-name problem, not a composition problem.
39
+ */
40
+ readonly noRoute: "NO_ROUTE";
25
41
  /**
26
42
  * A scope-routed module was called without a `scope`. Parameters: `module`.
27
43
  *
@@ -118,6 +134,15 @@ export declare const ShellCapabilities: {
118
134
  readonly savePicker: "savePicker";
119
135
  readonly secondaryWindows: "secondaryWindows";
120
136
  readonly tray: "tray";
137
+ /**
138
+ * The host can put a FILE LIST on the clipboard, so the user can paste into Explorer, Finder or a
139
+ * file manager. No web API expresses this, and a phone's pasteboard has none — so it is the one part
140
+ * of the clipboard worth branching on.
141
+ *
142
+ * ⚠ It says nothing about the rest: text and bytes work everywhere, and the gesture-driven half is
143
+ * `navigator.clipboard`'s job, not the host's.
144
+ */
145
+ readonly clipboardFiles: "clipboardFiles";
121
146
  /**
122
147
  * The host can serve LOCAL FILES to this page — media, images, documents, exports — through its resource
123
148
  * interceptor. Pair it with {@link mediaUrl}.
package/dist/types.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The Shenora IPC wire contract — the TS mirror of the `Shenora.Ipc` C# envelopes (names are
2
+ * The Shenora IPC wire contract — the TS mirror of the `Shenora.Core.Ipc` C# envelopes (names are
3
3
  * pinned on both sides; the host serializes camelCase). Transport-neutral: the same envelopes
4
4
  * travel over WebView2 postMessage, a WebSocket, or a mobile shell's channel.
5
5
  */
@@ -21,7 +21,23 @@ export const HANDSHAKE_TYPE = 'READY';
21
21
  */
22
22
  export const IpcErrorCodes = {
23
23
  unknownError: 'UNKNOWN_ERROR',
24
+ /**
25
+ * **No MODULE claimed the request** — nothing on the host answers that name. Parameters:
26
+ * `module`, `type`.
27
+ *
28
+ * ⚠ Distinct from {@link noRoute}, and the split is what makes it actionable: this means the module
29
+ * was never registered host-side, while `noRoute` means it WAS and does not know that type. Opposite
30
+ * fixes — wire the module up, versus correct a route name — so collapsing both into `NO_HANDLER` with
31
+ * identical parameters leaves a dead page undiagnosable from the wire.
32
+ */
24
33
  noHandler: 'NO_HANDLER',
34
+ /**
35
+ * **The module answered but has no route of that type.** Parameters: `module`, `type`.
36
+ *
37
+ * Seeing this is proof the module IS registered and mapped, which is exactly what {@link noHandler}
38
+ * cannot tell you — so this is a route-name problem, not a composition problem.
39
+ */
40
+ noRoute: 'NO_ROUTE',
25
41
  /**
26
42
  * A scope-routed module was called without a `scope`. Parameters: `module`.
27
43
  *
@@ -76,6 +92,15 @@ export const ShellCapabilities = {
76
92
  savePicker: 'savePicker',
77
93
  secondaryWindows: 'secondaryWindows',
78
94
  tray: 'tray',
95
+ /**
96
+ * The host can put a FILE LIST on the clipboard, so the user can paste into Explorer, Finder or a
97
+ * file manager. No web API expresses this, and a phone's pasteboard has none — so it is the one part
98
+ * of the clipboard worth branching on.
99
+ *
100
+ * ⚠ It says nothing about the rest: text and bytes work everywhere, and the gesture-driven half is
101
+ * `navigator.clipboard`'s job, not the host's.
102
+ */
103
+ clipboardFiles: 'clipboardFiles',
79
104
  /**
80
105
  * The host can serve LOCAL FILES to this page — media, images, documents, exports — through its resource
81
106
  * interceptor. Pair it with {@link mediaUrl}.
@@ -1,8 +1,8 @@
1
1
  import { type RefObject } from 'react';
2
2
  import { type ShenoraBridge } from './bridge.js';
3
3
  import { type ShenoraEventBus } from './eventBus.js';
4
- /** The reserved module the drop-zone stack speaks (host: `DropZoneManager`/`DropZoneFacade`). */
5
- export declare const DROP_ZONE_MODULE = "DROP_ZONE";
4
+ /** The reserved module the drop-zone stack speaks (host: `DropZoneManager`/`DropZoneModule`). */
5
+ export declare const DROP_ZONE_MODULE = "SHENORA.DROPZONE";
6
6
  /** A native file drop delivered to a zone. */
7
7
  export interface DropZoneFileDrop {
8
8
  zoneId: string;
@@ -29,17 +29,30 @@ export interface UseDropZoneOptions {
29
29
  onDrop: (files: string[], drop: DropZoneFileDrop) => void;
30
30
  /** False = zone torn down (same as unmount). Default true. */
31
31
  enabled?: boolean;
32
- /** Stable zone id; default: generated per mount. */
32
+ /** Stable zone id; default: generated per mount. ⚠ Read on the FIRST render only — changing it later
33
+ * does not re-register the zone, which is what "stable" means here. */
33
34
  zoneId?: string;
34
35
  /**
35
36
  * Class toggled on the element while a file drag hovers the zone. UNSTYLED — headless (D13):
36
37
  * the library ships no CSS; style it in the app. Default `"shenora-drop-hover"`.
38
+ * ⚠ Read on the FIRST render only, like {@link UseDropZoneOptions.zoneId}: the hover effect
39
+ * captures it for its cleanup while the drop path reads it live, so a mid-hover change would add one
40
+ * class and remove another, leaving the first stuck on the element. Switch it by remounting.
37
41
  */
38
42
  dropClassName?: string;
39
43
  /** The bridge to speak over. Default: the shared default bridge. */
40
44
  bridge?: ShenoraBridge;
41
45
  /** The event bus host notifications arrive on. Default: the shared bus. */
42
46
  bus?: ShenoraEventBus;
47
+ /**
48
+ * Where a failed REGISTER / UPDATE / SHOW / UNREGISTER is reported. Default: `console.error`.
49
+ *
50
+ * ⚠ Worth routing somewhere real, because a failure here is INVISIBLE in the UI: the page renders
51
+ * exactly as it should and files simply do not drop. This hook was the last error path in the package
52
+ * that could only ever reach the console — `bridge.ts`'s `onPostError`, `store.ts`'s `onError` and
53
+ * `segmentBinder.ts`'s `onDiagnostic` all take an app sink.
54
+ */
55
+ onError?: (error: unknown, route: string) => void;
43
56
  }
44
57
  /**
45
58
  * **The file-drop API for a Shenora page. Do not use the DOM's own drop event for files —
@@ -2,8 +2,8 @@ import { useEffect, useRef, useState } from 'react';
2
2
  import { getBridge } from './bridge.js';
3
3
  import { eventBus as defaultEventBus } from './eventBus.js';
4
4
  import { debounce, randomId } from './internal.js';
5
- /** The reserved module the drop-zone stack speaks (host: `DropZoneManager`/`DropZoneFacade`). */
6
- export const DROP_ZONE_MODULE = 'DROP_ZONE';
5
+ /** The reserved module the drop-zone stack speaks (host: `DropZoneManager`/`DropZoneModule`). */
6
+ export const DROP_ZONE_MODULE = 'SHENORA.DROPZONE';
7
7
  const newZoneId = () => randomId('drop-zone-');
8
8
  /**
9
9
  * **The file-drop API for a Shenora page. Do not use the DOM's own drop event for files —
@@ -30,13 +30,34 @@ const newZoneId = () => randomId('drop-zone-');
30
30
  */
31
31
  export function useDropZone(options) {
32
32
  const { targetRef, enabled = true } = options;
33
- const zoneIdRef = useRef(options.zoneId ?? newZoneId());
33
+ // ⚠ LAZY, because `useRef(newZoneId())` evaluates its argument on EVERY render and keeps only the
34
+ // first — so the generator ran a `crypto.randomUUID()` per render of every drop zone, for a value
35
+ // used once. The empty string is a safe sentinel: a generated id is never empty, and a caller who
36
+ // passes `zoneId: ''` short-circuits the `??` and so still never reaches the generator.
37
+ const zoneIdRef = useRef('');
38
+ if (zoneIdRef.current === '')
39
+ zoneIdRef.current = options.zoneId ?? newZoneId();
40
+ // ⚠ Read ONCE, like the id, and unlike `onDrop`/`bridge` below which track the latest value. That is
41
+ // deliberate rather than an oversight: the hover effect captures this class for its cleanup, while
42
+ // the FILE_DROP effect reads it live, so a value that could change mid-hover would let one path add
43
+ // class A and another remove class B — leaving A stuck on the element with no drag in progress. The
44
+ // default is a constant, so unlike the id there is nothing here worth making lazy.
34
45
  const dropClassRef = useRef(options.dropClassName ?? 'shenora-drop-hover');
35
46
  const onDropRef = useRef(options.onDrop);
36
47
  onDropRef.current = options.onDrop;
37
48
  const bridgeRef = useRef(options.bridge);
38
49
  bridgeRef.current = options.bridge;
39
50
  const bus = options.bus ?? defaultEventBus;
51
+ // Tracks the latest handler (like `onDrop`), so a cleanup that runs long after mount still reports
52
+ // through the sink the app has NOW. Only when the app supplied none does this log — a caller that
53
+ // took `onError` owns its reporting and must not be double-logged, the rule the package's other
54
+ // three sinks follow.
55
+ const onErrorRef = useRef(options.onError);
56
+ onErrorRef.current = options.onError;
57
+ const reportRef = useRef(() => { });
58
+ reportRef.current = (error, route) => onErrorRef.current
59
+ ? onErrorRef.current(error, route)
60
+ : console.error(`[shenora] drop-zone ${route} failed:`, error);
40
61
  // Make the ref's CONTENT reactive (P5.5 H2). `targetRef` is a stable object, so effects keyed on it
41
62
  // run exactly once — and if `targetRef.current` was null on that run (a conditionally-rendered
42
63
  // target, or any order where the ref is attached after the first commit) the effect bailed out and
@@ -91,7 +112,7 @@ export function useDropZone(options) {
91
112
  .then(() => {
92
113
  if (epochRef.current === epoch)
93
114
  isRegisteredRef.current = true;
94
- }, (error) => console.error('[shenora] drop-zone REGISTER failed:', error))
115
+ }, (error) => reportRef.current(error, 'REGISTER'))
95
116
  .finally(() => {
96
117
  if (epochRef.current === epoch)
97
118
  registeringRef.current = false;
@@ -101,7 +122,7 @@ export function useDropZone(options) {
101
122
  lastBoundsRef.current = bounds;
102
123
  bridge
103
124
  .invoke(DROP_ZONE_MODULE, 'UPDATE', { payload: { zoneId: zoneIdRef.current, ...bounds } })
104
- .catch((error) => console.error('[shenora] drop-zone UPDATE failed:', error));
125
+ .catch((error) => reportRef.current(error, 'UPDATE'));
105
126
  }
106
127
  };
107
128
  // Track the element and keep the native overlay in sync.
@@ -115,7 +136,7 @@ export function useDropZone(options) {
115
136
  const sendShow = debounce(() => {
116
137
  (bridgeRef.current ?? getBridge())
117
138
  .invoke(DROP_ZONE_MODULE, 'SHOW', { payload: { zoneId: zoneIdRef.current } })
118
- .catch((error) => console.error('[shenora] drop-zone SHOW failed:', error));
139
+ .catch((error) => reportRef.current(error, 'SHOW'));
119
140
  }, 100);
120
141
  // The element's mouseleave (and the window losing focus) re-arm the overlay — native
121
142
  // MouseLeave alone is unreliable through the WebView.
@@ -152,7 +173,7 @@ export function useDropZone(options) {
152
173
  if (attemptedRef.current) {
153
174
  (bridgeRef.current ?? getBridge())
154
175
  .invoke(DROP_ZONE_MODULE, 'UNREGISTER', { payload: { zoneId: zoneIdRef.current } })
155
- .catch((error) => console.error('[shenora] drop-zone UNREGISTER failed:', error));
176
+ .catch((error) => reportRef.current(error, 'UNREGISTER'));
156
177
  epochRef.current++; // invalidate any in-flight REGISTER's ack (see epochRef)
157
178
  isRegisteredRef.current = false;
158
179
  registeringRef.current = false; // a remount must re-send immediately
@@ -33,7 +33,7 @@ interface WindowRequests {
33
33
  };
34
34
  }
35
35
  /**
36
- * Typed client for the host's `WINDOW` module (`WindowCommandFacade` in Shenora.Windows) —
36
+ * Typed client for the host's `SHENORA.WINDOW` module (`WindowCommandModule` in Shenora.Windows) —
37
37
  * drive the frameless window's chrome from the page: chrome buttons call
38
38
  * `minimize`/`toggleMaximize`/`close`, the header's `onMouseDown` calls `startDrag` (the window
39
39
  * then drags natively — snap and multi-monitor included), and a thin strip at the very top
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
2
2
  import { debounce } from './internal.js';
3
3
  import { BaseModuleService } from './moduleService.js';
4
4
  /**
5
- * Typed client for the host's `WINDOW` module (`WindowCommandFacade` in Shenora.Windows) —
5
+ * Typed client for the host's `SHENORA.WINDOW` module (`WindowCommandModule` in Shenora.Windows) —
6
6
  * drive the frameless window's chrome from the page: chrome buttons call
7
7
  * `minimize`/`toggleMaximize`/`close`, the header's `onMouseDown` calls `startDrag` (the window
8
8
  * then drags natively — snap and multi-monitor included), and a thin strip at the very top
@@ -11,7 +11,7 @@ import { BaseModuleService } from './moduleService.js';
11
11
  */
12
12
  export class WindowCommands extends BaseModuleService {
13
13
  constructor(bridge) {
14
- super('WINDOW', bridge);
14
+ super('SHENORA.WINDOW', bridge);
15
15
  }
16
16
  minimize() {
17
17
  return this.send('MINIMIZE');
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shenora/react",
3
- "version": "0.10.0",
4
- "description": "React client for Shenora desktop hosts: correlated invoke/send/subscribe over the WebView2 bridge, typed module services, hooks, and a browser fallback for pure-UI development.",
3
+ "version": "0.11.0",
4
+ "description": "React client for Shenora hosts on Windows, Android and iOS: correlated invoke/send/subscribe over the desktop postMessage bridge or the MAUI HybridWebView transport, typed module services, host-backed stores, and hooks for request tracking and media playback. Drop zones and window commands are desktop-only, because the capabilities are. Ships a browser fallback for pure-UI development.",
5
5
  "license": "MIT",
6
6
  "author": "Jiarong Gu",
7
7
  "repository": {
@@ -14,6 +14,10 @@
14
14
  "shenora",
15
15
  "webview2",
16
16
  "desktop",
17
+ "android",
18
+ "ios",
19
+ "maui",
20
+ "hybrid",
17
21
  "ipc",
18
22
  "react",
19
23
  "winforms",
@@ -44,7 +48,9 @@
44
48
  "//typecheck": "The ONLY thing that type-checks the tests — `build` excludes them and vitest transpiles without checking, so `@ts-expect-error` assertions (which pin the typed-service generic) are inert without this. Run by dev.mjs verify.",
45
49
  "typecheck": "tsc -p tsconfig.json",
46
50
  "test": "vitest run",
47
- "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\""
51
+ "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
52
+ "//typecheck:floor": "The peerDependency FLOOR is a tested claim, not an assumption: type-checks the SHIPPED sources against React 18 types while everything else builds on 19, so an API that does not exist in 18 (useActionState, use, …) fails HERE instead of in a React 18 consumer's build. Run by dev.mjs verify.",
53
+ "typecheck:floor": "tsc -p tsconfig.react18.json"
48
54
  },
49
55
  "peerDependencies": {
50
56
  "react": ">=18"
@@ -52,6 +58,7 @@
52
58
  "devDependencies": {
53
59
  "@testing-library/react": "^16.3.2",
54
60
  "@types/react": "^19.2.17",
61
+ "@types/react18": "npm:@types/react@^18",
55
62
  "jsdom": "^29.1.1",
56
63
  "react": "^19.2.8",
57
64
  "react-dom": "^19.2.8",