@shenora/react 0.15.0 → 0.17.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/transport.js CHANGED
@@ -1,12 +1,16 @@
1
+ /** The global the Chromium shell marks a document with. Mirrored by `ChromiumTransport.HostGlobal`. */
2
+ export const CHROMIUM_HOST_GLOBAL = '__shenora_chromium';
3
+ /** What the Chromium shell calls to push a message. Mirrored by `ChromiumTransport.ReceiveMember`. */
4
+ export const CHROMIUM_RECEIVE = 'receive';
1
5
  const webViewWindow = () => typeof window === 'undefined' ? undefined : window;
2
6
  /**
3
- * True when running inside ANY Shenora host — a WebView2 desktop shell or a MAUI `HybridWebView` — i.e.
4
- * when a transport to the host exists. In a plain browser this is false and callers should fall back to
7
+ * True when running inside ANY Shenora host — a WebView2 desktop shell, the Chromium shell or a
8
+ * `ChromiumView`, or a MAUI `HybridWebView` — i.e. when a transport to the host exists. In a plain browser this is false and callers should fall back to
5
9
  * browser-only behavior. The question is "is there a host", never "is it WebView2".
6
10
  */
7
11
  export function isShenoraAvailable() {
8
12
  const host = webViewWindow();
9
- return !!host?.chrome?.webview || !!host?.HybridWebView;
13
+ return !!host?.chrome?.webview || !!host?.HybridWebView || !!chromiumHost(host);
10
14
  }
11
15
  /** The WebView2 postMessage transport, or null outside a WebView2 host. */
12
16
  export function createWebView2Transport() {
@@ -55,12 +59,59 @@ export function createHybridWebViewTransport() {
55
59
  },
56
60
  };
57
61
  }
62
+ /** The marker, when it is well-formed; anything else on that global is not ours. */
63
+ function chromiumHost(host) {
64
+ const marker = host?.[CHROMIUM_HOST_GLOBAL];
65
+ return marker && typeof marker === 'object' && typeof marker.ipc === 'string' ? marker : undefined;
66
+ }
67
+ /**
68
+ * Everyone listening on one marker. The host calls ONE function, so it fans out here: a second
69
+ * transport (a bridge replaced by `configureBridge`, say) must not silence the first, which is how two
70
+ * WebView2 listeners behave too.
71
+ */
72
+ const chromiumListeners = new WeakMap();
73
+ /**
74
+ * The Chromium shell's transport, or null outside it. There is no code in the renderer: the page posts
75
+ * each envelope with `fetch` to the marker's same-origin `ipc` route, which the shell answers from its
76
+ * resource handler, and the shell pushes by calling the marker's `receive`.
77
+ */
78
+ export function createChromiumTransport() {
79
+ const marker = chromiumHost(webViewWindow());
80
+ if (!marker)
81
+ return null;
82
+ let listeners = chromiumListeners.get(marker);
83
+ if (!listeners) {
84
+ const set = new Set();
85
+ chromiumListeners.set(marker, set);
86
+ marker[CHROMIUM_RECEIVE] = (message) => {
87
+ // Narrow first: anything on the page can call this, and only strings are ours.
88
+ if (typeof message === 'string')
89
+ for (const listener of [...set])
90
+ listener(message);
91
+ };
92
+ listeners = set;
93
+ }
94
+ const own = listeners;
95
+ return {
96
+ // Each message is a request of its own. They reach the host in the order they were posted: measured,
97
+ // 1,500 of 1,500 one-way posts in order, though no spec promises it. A failed post is swallowed, since an
98
+ // unhandled rejection here would name nothing: an `invoke` then fails at the bridge's request timeout,
99
+ // which names the call, and a one-way `post` is lost.
100
+ post: (message) => {
101
+ void fetch(marker.ipc, { method: 'POST', body: message }).catch(() => undefined);
102
+ },
103
+ subscribe: (listener) => {
104
+ own.add(listener);
105
+ return () => { own.delete(listener); };
106
+ },
107
+ };
108
+ }
58
109
  /**
59
110
  * The transport for whichever Shenora host this page is running in, or null in a plain browser.
60
111
  * This is what the bridge uses by default, so an app that simply calls `invoke`/`post` works on the
61
- * desktop shell and the MAUI shell without knowing which one it is. A page is only ever in one of
62
- * them; WebView2 wins if both objects are somehow present.
112
+ * desktop shell, the MAUI shell and the Chromium shell without knowing which one it is. A page is only
113
+ * ever in one of them; the order decides only if several objects are somehow present.
63
114
  */
64
115
  export function createHostTransport() {
65
- return createWebView2Transport() ?? createHybridWebViewTransport();
116
+ return createWebView2Transport() ?? createHybridWebViewTransport() ?? createChromiumTransport();
66
117
  }
package/dist/types.d.ts CHANGED
@@ -131,6 +131,31 @@ export declare const ShellCapabilities: {
131
131
  * and allowed roots are the app's, and it says nothing about the URL SCHEME.
132
132
  */
133
133
  readonly localFiles: "localFiles";
134
+ /**
135
+ * The shell can HOLD the window at an orientation — see `WindowOrientation`. Android only today; the
136
+ * desktop has nothing to hold, and iOS refuses rather than half-doing it.
137
+ *
138
+ * ⚠ Branch on it, because the page's own fallback is real but weaker: `screen.orientation.lock()` is
139
+ * honoured only while the document is FULLSCREEN, and not at all in WKWebView. Absent here means take
140
+ * fullscreen first or leave rotation alone.
141
+ */
142
+ readonly windowOrientation: "windowOrientation";
143
+ /**
144
+ * The shell can draw the PICTURE itself, under a transparent region the page leaves — see
145
+ * `useMediaSurface`. The mobile shells; the desktop has none and does not need one.
146
+ *
147
+ * ⚠ Branch on it, but the fallback is a real player rather than a degraded one: absent, a `<video>`
148
+ * element is the picture, which is the right answer wherever the webview can decode the file. Present,
149
+ * the shell's own player opens what that element refuses.
150
+ *
151
+ * 🔴 It asserts TWO things: a surface exists AND a player is attached to draw into it. A host that
152
+ * advertises it on the strength of the surface alone gives you a hole with no decoder behind it — the
153
+ * controls render, nothing ever appears, and it is indistinguishable from a refused file.
154
+ *
155
+ * ⚠ It still says nothing about a GIVEN file — what the platform decodes is a per-stream question the
156
+ * host answers.
157
+ */
158
+ readonly mediaSurface: "mediaSurface";
134
159
  };
135
160
  /** The response envelope the host returns for an {@link IpcRequest}. */
136
161
  export interface IpcResponse<TData = unknown> {
package/dist/types.js CHANGED
@@ -101,4 +101,29 @@ export const ShellCapabilities = {
101
101
  * and allowed roots are the app's, and it says nothing about the URL SCHEME.
102
102
  */
103
103
  localFiles: 'localFiles',
104
+ /**
105
+ * The shell can HOLD the window at an orientation — see `WindowOrientation`. Android only today; the
106
+ * desktop has nothing to hold, and iOS refuses rather than half-doing it.
107
+ *
108
+ * ⚠ Branch on it, because the page's own fallback is real but weaker: `screen.orientation.lock()` is
109
+ * honoured only while the document is FULLSCREEN, and not at all in WKWebView. Absent here means take
110
+ * fullscreen first or leave rotation alone.
111
+ */
112
+ windowOrientation: 'windowOrientation',
113
+ /**
114
+ * The shell can draw the PICTURE itself, under a transparent region the page leaves — see
115
+ * `useMediaSurface`. The mobile shells; the desktop has none and does not need one.
116
+ *
117
+ * ⚠ Branch on it, but the fallback is a real player rather than a degraded one: absent, a `<video>`
118
+ * element is the picture, which is the right answer wherever the webview can decode the file. Present,
119
+ * the shell's own player opens what that element refuses.
120
+ *
121
+ * 🔴 It asserts TWO things: a surface exists AND a player is attached to draw into it. A host that
122
+ * advertises it on the strength of the surface alone gives you a hole with no decoder behind it — the
123
+ * controls render, nothing ever appears, and it is indistinguishable from a refused file.
124
+ *
125
+ * ⚠ It still says nothing about a GIVEN file — what the platform decodes is a per-stream question the
126
+ * host answers.
127
+ */
128
+ mediaSurface: 'mediaSurface',
104
129
  };
@@ -56,13 +56,16 @@ export interface UseDropZoneOptions {
56
56
  * across the IPC boundary before the app knows whether it wants any of them. This gives you `string[]`
57
57
  * OS paths instead — open lazily, stream, hash incrementally, move or link without copying.
58
58
  *
59
- * The host positions a transparent native overlay over the element to capture those paths, including
60
- * for drags started while the app is in the background. Bounds re-sync (debounced) on
61
- * resize/scroll/intersection changes; the host converts the CSS rect to physical pixels per-monitor.
59
+ * **On the WebView2 shell** the host positions a transparent native overlay over the element to capture
60
+ * those paths, including for drags started while the app is in the background. Bounds re-sync
61
+ * (debounced) on resize/scroll/intersection changes; the host converts the CSS rect to physical pixels
62
+ * per-monitor. How the visibility dance works: mouse leaves the element → SHOW (overlay up, ready to
63
+ * catch a drag); mouse enters → the host hides the overlay (hover effects keep working); an inactive
64
+ * window always shows overlays (background drag-drop); while the overlay is visible the host emits
65
+ * DRAG_ENTER/DRAG_LEAVE for CSS feedback.
62
66
  *
63
- * How the visibility dance works: mouse leaves the element → SHOW (overlay up, ready to catch a
64
- * drag); mouse enters → the host hides the overlay (hover effects keep working); an inactive
65
- * window always shows overlays (background drag-drop); while the overlay is visible the host
66
- * emits DRAG_ENTER/DRAG_LEAVE for CSS feedback.
67
+ * **On the Chromium shell there is no overlay.** The engine hands the host the drag's real paths as it
68
+ * enters, so the host answers REGISTER with `pageDrop`, this hook takes the page's own drag events on the
69
+ * element, and a drop there asks the host for the paths. Your code is the same on both.
67
70
  */
68
71
  export declare function useDropZone(options: UseDropZoneOptions): void;
@@ -2,6 +2,7 @@ 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
+ import { useRefElement } from './internalHooks.js';
5
6
  /** The reserved module the drop-zone stack speaks (host: `DropZoneManager`/`DropZoneModule`). */
6
7
  export const DROP_ZONE_MODULE = 'SHENORA.DROPZONE';
7
8
  const newZoneId = () => randomId('drop-zone-');
@@ -12,14 +13,17 @@ const newZoneId = () => randomId('drop-zone-');
12
13
  * across the IPC boundary before the app knows whether it wants any of them. This gives you `string[]`
13
14
  * OS paths instead — open lazily, stream, hash incrementally, move or link without copying.
14
15
  *
15
- * The host positions a transparent native overlay over the element to capture those paths, including
16
- * for drags started while the app is in the background. Bounds re-sync (debounced) on
17
- * resize/scroll/intersection changes; the host converts the CSS rect to physical pixels per-monitor.
16
+ * **On the WebView2 shell** the host positions a transparent native overlay over the element to capture
17
+ * those paths, including for drags started while the app is in the background. Bounds re-sync
18
+ * (debounced) on resize/scroll/intersection changes; the host converts the CSS rect to physical pixels
19
+ * per-monitor. How the visibility dance works: mouse leaves the element → SHOW (overlay up, ready to
20
+ * catch a drag); mouse enters → the host hides the overlay (hover effects keep working); an inactive
21
+ * window always shows overlays (background drag-drop); while the overlay is visible the host emits
22
+ * DRAG_ENTER/DRAG_LEAVE for CSS feedback.
18
23
  *
19
- * How the visibility dance works: mouse leaves the element → SHOW (overlay up, ready to catch a
20
- * drag); mouse enters → the host hides the overlay (hover effects keep working); an inactive
21
- * window always shows overlays (background drag-drop); while the overlay is visible the host
22
- * emits DRAG_ENTER/DRAG_LEAVE for CSS feedback.
24
+ * **On the Chromium shell there is no overlay.** The engine hands the host the drag's real paths as it
25
+ * enters, so the host answers REGISTER with `pageDrop`, this hook takes the page's own drag events on the
26
+ * element, and a drop there asks the host for the paths. Your code is the same on both.
23
27
  */
24
28
  export function useDropZone(options) {
25
29
  const { targetRef, enabled = true } = options;
@@ -44,16 +48,8 @@ export function useDropZone(options) {
44
48
  reportRef.current = (error, route) => onErrorRef.current
45
49
  ? onErrorRef.current(error, route)
46
50
  : console.error(`[shenora] drop-zone ${route} failed:`, error);
47
- // 🔴 Make the ref's CONTENT reactive. `targetRef` is a stable object, so an effect keyed on it runs
48
- // exactly once — and if `targetRef.current` is null on that run (a conditionally-rendered target, or
49
- // any order where the ref is attached after the first commit) the effect bails out and NEVER re-runs:
50
- // the zone is silently dead for the component's whole life, with no error anywhere. A ref mutation
51
- // triggers no render, so this effect has NO dependency array; `setElement` with an unchanged value is
52
- // a React no-op, so it cannot loop.
53
- const [element, setElement] = useState(null);
54
- useEffect(() => {
55
- setElement(targetRef.current ?? null);
56
- });
51
+ // 🔴 The ref's CONTENT, reactive: a target rendered after the first commit would otherwise never register.
52
+ const element = useRefElement(targetRef);
57
53
  const isRegisteredRef = useRef(false);
58
54
  // Whether a REGISTER has ever been SENT for this zone (even if not yet acked). The cleanup
59
55
  // unregisters on THIS (not on the ack) so a fast unmount before REGISTER resolves still tears
@@ -66,6 +62,9 @@ export function useDropZone(options) {
66
62
  // never exists again. Cleanup bumps the epoch; acks from an older epoch are ignored.
67
63
  const epochRef = useRef(0);
68
64
  const lastBoundsRef = useRef({ x: 0, y: 0, width: 0, height: 0 });
65
+ // The host said the PAGE delivers drops (the Chromium shell). Read by the DOM listeners at event time,
66
+ // so they do nothing until REGISTER is answered, and nothing at all on the WebView2 shell.
67
+ const pageDropRef = useRef(false);
69
68
  const syncBoundsRef = useRef(() => { });
70
69
  syncBoundsRef.current = () => {
71
70
  const element = targetRef.current;
@@ -93,9 +92,11 @@ export function useDropZone(options) {
93
92
  const epoch = epochRef.current;
94
93
  bridge
95
94
  .invoke(DROP_ZONE_MODULE, 'REGISTER', { payload: { zoneId: zoneIdRef.current, ...bounds } })
96
- .then(() => {
97
- if (epochRef.current === epoch)
98
- isRegisteredRef.current = true;
95
+ .then((registration) => {
96
+ if (epochRef.current !== epoch)
97
+ return;
98
+ isRegisteredRef.current = true;
99
+ pageDropRef.current = registration?.pageDrop === true;
99
100
  }, (error) => reportRef.current(error, 'REGISTER'))
100
101
  .finally(() => {
101
102
  if (epochRef.current === epoch)
@@ -118,6 +119,8 @@ export function useDropZone(options) {
118
119
  const syncBounds = debounce(() => syncBoundsRef.current(), 100);
119
120
  syncBoundsRef.current();
120
121
  const sendShow = debounce(() => {
122
+ if (pageDropRef.current)
123
+ return; // no overlay to raise
121
124
  (bridgeRef.current ?? getBridge())
122
125
  .invoke(DROP_ZONE_MODULE, 'SHOW', { payload: { zoneId: zoneIdRef.current } })
123
126
  .catch((error) => reportRef.current(error, 'SHOW'));
@@ -161,20 +164,85 @@ export function useDropZone(options) {
161
164
  isRegisteredRef.current = false;
162
165
  registeringRef.current = false; // a remount must re-send immediately
163
166
  attemptedRef.current = false;
167
+ pageDropRef.current = false;
164
168
  }
165
169
  };
166
170
  }, [enabled, element]);
167
- // Drag-hover CSS feedback.
171
+ // The Chromium shell's drops: the page's own drag events on the element, acted on only once the host
172
+ // answered REGISTER with `pageDrop`. Claiming `dragover` is what makes the element a drop target at all.
173
+ useEffect(() => {
174
+ if (!enabled || !element)
175
+ return;
176
+ const dropClass = dropClassRef.current;
177
+ const carriesFiles = (event) => [...(event.dataTransfer?.types ?? [])].includes('Files');
178
+ // dragenter/dragleave fire for every child the pointer crosses, so count them rather than trusting one.
179
+ let depth = 0;
180
+ const onDragEnter = (event) => {
181
+ if (!pageDropRef.current || !carriesFiles(event))
182
+ return;
183
+ event.preventDefault();
184
+ depth++;
185
+ element.classList.add(dropClass);
186
+ };
187
+ const onDragOver = (event) => {
188
+ if (!pageDropRef.current || !carriesFiles(event))
189
+ return;
190
+ event.preventDefault();
191
+ if (event.dataTransfer)
192
+ event.dataTransfer.dropEffect = 'copy';
193
+ };
194
+ const onDragLeave = () => {
195
+ if (!pageDropRef.current)
196
+ return;
197
+ depth = Math.max(0, depth - 1);
198
+ if (depth === 0)
199
+ element.classList.remove(dropClass);
200
+ };
201
+ const onDrop = (event) => {
202
+ if (!pageDropRef.current || !carriesFiles(event))
203
+ return;
204
+ event.preventDefault();
205
+ depth = 0;
206
+ element.classList.remove(dropClass);
207
+ const rect = element.getBoundingClientRect();
208
+ const position = {
209
+ x: Math.round((event.clientX - rect.left) * devicePixelRatio),
210
+ y: Math.round((event.clientY - rect.top) * devicePixelRatio),
211
+ };
212
+ const zoneId = zoneIdRef.current;
213
+ (bridgeRef.current ?? getBridge())
214
+ .invoke(DROP_ZONE_MODULE, 'DROP', { payload: { zoneId } })
215
+ .then((answer) => {
216
+ const files = answer?.files ?? [];
217
+ if (files.length > 0)
218
+ onDropRef.current(files, { zoneId, files, position });
219
+ }, (error) => reportRef.current(error, 'DROP'));
220
+ };
221
+ element.addEventListener('dragenter', onDragEnter);
222
+ element.addEventListener('dragover', onDragOver);
223
+ element.addEventListener('dragleave', onDragLeave);
224
+ element.addEventListener('drop', onDrop);
225
+ return () => {
226
+ element.removeEventListener('dragenter', onDragEnter);
227
+ element.removeEventListener('dragover', onDragOver);
228
+ element.removeEventListener('dragleave', onDragLeave);
229
+ element.removeEventListener('drop', onDrop);
230
+ element.classList.remove(dropClass);
231
+ };
232
+ }, [enabled, element]);
233
+ // Drag-hover CSS feedback, from the WebView2 shell's overlay. Where the page delivers drops itself, these bus events
234
+ // are some OTHER page's: an app with both engines has a WebView2 page announcing its drags to every page, and a zone
235
+ // of the same id there would otherwise light this one, or drop its files here.
168
236
  useEffect(() => {
169
237
  if (!enabled || !element)
170
238
  return;
171
239
  const dropClass = dropClassRef.current;
172
240
  const offEnter = bus.subscribe(DROP_ZONE_MODULE, 'DRAG_ENTER', (event) => {
173
- if (event.payload?.zoneId === zoneIdRef.current)
241
+ if (!pageDropRef.current && event.payload?.zoneId === zoneIdRef.current)
174
242
  element.classList.add(dropClass);
175
243
  });
176
244
  const offLeave = bus.subscribe(DROP_ZONE_MODULE, 'DRAG_LEAVE', (event) => {
177
- if (event.payload?.zoneId === zoneIdRef.current)
245
+ if (!pageDropRef.current && event.payload?.zoneId === zoneIdRef.current)
178
246
  element.classList.remove(dropClass);
179
247
  });
180
248
  return () => {
@@ -189,8 +257,8 @@ export function useDropZone(options) {
189
257
  return;
190
258
  return bus.subscribe(DROP_ZONE_MODULE, 'FILE_DROP', (event) => {
191
259
  const drop = event.payload;
192
- if (!drop || drop.zoneId !== zoneIdRef.current)
193
- return;
260
+ if (pageDropRef.current || !drop || drop.zoneId !== zoneIdRef.current)
261
+ return; // see the hover feedback above
194
262
  targetRef.current?.classList.remove(dropClassRef.current);
195
263
  onDropRef.current(drop.files, drop);
196
264
  });
@@ -1,4 +1,5 @@
1
1
  import type { ShenoraBridge } from './bridge.js';
2
+ import type { ShenoraEventBus } from './eventBus.js';
2
3
  import { BaseModuleService } from './moduleService.js';
3
4
  /** The top resize edges — the only ones that exist: the frameless technique keeps the native
4
5
  * side/bottom resize borders, so only the top (covered by the WebView) needs page-side help. */
@@ -6,8 +7,8 @@ export type WindowResizeEdge = 'top' | 'topLeft' | 'topRight';
6
7
  /** Which system caption button a page-drawn region stands in for (mirrors the host's enum). */
7
8
  export type CaptionButtonKind = 'minimize' | 'maximize' | 'close';
8
9
  /**
9
- * Where the page drew one caption button, in CSS px relative to the WebView2 — i.e. straight out of
10
- * `getBoundingClientRect()`. The host converts to physical px using the control's DeviceDpi.
10
+ * Where the page drew one caption button, in CSS px relative to the page — i.e. straight out of
11
+ * `getBoundingClientRect()`. The host converts to physical px at the window's DPI.
11
12
  */
12
13
  export interface CaptionButtonRect {
13
14
  kind: CaptionButtonKind;
@@ -16,6 +17,42 @@ export interface CaptionButtonRect {
16
17
  width: number;
17
18
  height: number;
18
19
  }
20
+ /**
21
+ * What the OS is doing to the page's caption buttons: the one the pointer is over, and the one being pressed.
22
+ * Absent means none.
23
+ */
24
+ export interface CaptionButtonState {
25
+ hot?: CaptionButtonKind;
26
+ pressed?: CaptionButtonKind;
27
+ }
28
+ /**
29
+ * The colours of the caption buttons the window paints (`NativeCaptionButtons`), each a CSS hex colour: `#rgb`,
30
+ * `#rgba`, `#rrggbb` or `#rrggbbaa`. Mirrors the host's `CaptionButtonColors`.
31
+ */
32
+ export interface CaptionButtonColors {
33
+ /** The title bar's own colour. The WebView2 shell paints it behind its buttons; the Chromium shell's idle buttons
34
+ * show the page through them, so it is unused there. */
35
+ surface: string;
36
+ /** A hovered minimize or maximize button's background. */
37
+ hover: string;
38
+ /** A pressed minimize or maximize button's background. */
39
+ pressed: string;
40
+ /** The glyphs. */
41
+ glyph: string;
42
+ /** A hovered close button's background, red by the platform's convention. */
43
+ closeHover: string;
44
+ /** A pressed close button's background. */
45
+ closePressed: string;
46
+ /** The close glyph while close is hovered or pressed. Absent: `glyph`. */
47
+ closeGlyphHot?: string;
48
+ /** The idle glyphs while the window is inactive. Absent: `glyph` at about a third of its opacity. */
49
+ inactiveGlyph?: string;
50
+ }
51
+ /** The host's `SHENORA.WINDOW` event names, pinned against it by `WireMirrorTests`. */
52
+ export declare const WindowEventTypes: {
53
+ /** A {@link CaptionButtonState}, sent to the window's own page. See {@link useCaptionButtonState}. */
54
+ readonly CaptionButtonState: "CAPTION_BUTTON_STATE";
55
+ };
19
56
  interface WindowRequests {
20
57
  MINIMIZE: void;
21
58
  TOGGLE_MAXIMIZE: void;
@@ -25,12 +62,16 @@ interface WindowRequests {
25
62
  START_RESIZE: {
26
63
  edge: WindowResizeEdge;
27
64
  };
65
+ SHOW_SYSTEM_MENU: void;
28
66
  SET_THEME: {
29
67
  dark: boolean;
30
68
  };
31
69
  SET_CAPTION_BUTTONS: {
32
70
  buttons: CaptionButtonRect[];
33
71
  };
72
+ SET_CAPTION_BUTTON_COLORS: {
73
+ colors?: CaptionButtonColors;
74
+ };
34
75
  }
35
76
  /**
36
77
  * Typed client for the host's `SHENORA.WINDOW` module (`WindowCommandModule` in Shenora.Windows) —
@@ -51,6 +92,13 @@ export declare class WindowCommands extends BaseModuleService<WindowRequests> {
51
92
  startDrag(): Promise<void>;
52
93
  /** Call from the top strip's `onMouseDown` — hands off to the OS size loop. */
53
94
  startResize(edge?: WindowResizeEdge): Promise<void>;
95
+ /**
96
+ * Open the window's system menu at the pointer, as a right click on a real caption does. Call from the header's
97
+ * `onContextMenu`, and `preventDefault()` there so the browser's own menu does not open too. It resolves as the
98
+ * menu opens, not when it closes. A Chromium page's `-webkit-app-region: drag` area opens the menu on a right click
99
+ * without it.
100
+ */
101
+ showSystemMenu(): Promise<void>;
54
102
  /** Resync the native chrome to the app theme (host `WindowCommandOptions.ApplyTheme`). */
55
103
  setTheme(dark: boolean): Promise<void>;
56
104
  /**
@@ -58,15 +106,41 @@ export declare class WindowCommands extends BaseModuleService<WindowRequests> {
58
106
  * chiefly so Windows 11 offers **Snap Layouts** on the maximize button.
59
107
  *
60
108
  * ⚠ The host then takes over CLICKS in those rects and performs minimize/maximize/close itself, so
61
- * your `onClick` handlers stop firing there. CSS `:hover` stops firing too — subscribe to the host's
62
- * caption-button state to render hot/pressed, which is also the only way to stay hot while the
63
- * pointer is over the snap flyout, a different window.
109
+ * your `onClick` handlers stop firing there. CSS `:hover` stops firing too: render hot and pressed from
110
+ * {@link useCaptionButtonState}.
111
+ *
112
+ * ⚠ The Chromium shell forgets the rects when a new document loads, so send them from the page on
113
+ * every load as well as on every layout change.
114
+ *
115
+ * On either shell the window can paint the buttons itself instead (`NativeCaptionButtons` on
116
+ * `OptimizedFormOptions` or `ChromiumWindowOptions`): then the page reserves the rects and draws nothing
117
+ * there. The Chromium shell's painted buttons follow {@link WindowCommands.setTheme}.
64
118
  *
65
119
  * ⚠ Re-send on every layout change: the rectangles are a snapshot, and a stale one moves the
66
120
  * hit-test off the button the user can see. Pass an empty array to hand the pixels back to the page.
67
121
  */
68
122
  setCaptionButtons(buttons: CaptionButtonRect[]): Promise<void>;
123
+ /**
124
+ * Colour the caption buttons the window paints, to match the page's title bar; send them again when the page's
125
+ * theme changes. `null` goes back to the default: on the Chromium shell the colours of {@link WindowCommands.setTheme},
126
+ * on the WebView2 shell a fallback from the form's own colour (it replaces `OptimizedForm.CaptionButtonColors`).
127
+ * The row's height is the page's: the rects sent to {@link WindowCommands.setCaptionButtons}.
128
+ *
129
+ * ⚠ Rejects with `NO_ROUTE` where the window does not paint its buttons, and with `INVALID_PAYLOAD_VALUE` for a
130
+ * colour that is not CSS hex.
131
+ */
132
+ setCaptionButtonColors(colors: CaptionButtonColors | null): Promise<void>;
69
133
  }
134
+ /**
135
+ * Which caption button to render hot or pressed, for buttons registered with
136
+ * {@link WindowCommands.setCaptionButtons}, where CSS `:hover` no longer fires. The Chromium shell sends it to
137
+ * the window's own page as the pointer moves. The WebView2 shell sends nothing by itself: with
138
+ * `NativeCaptionButtons` it paints the caption buttons itself, and an app drawing its own there can emit this
139
+ * event from `OptimizedForm.CaptionButtonStateChanged`.
140
+ */
141
+ export declare function useCaptionButtonState(options?: {
142
+ bus?: ShenoraEventBus;
143
+ }): CaptionButtonState;
70
144
  /**
71
145
  * The authoritative maximize state, re-queried when a resize SETTLES — a maximize/restore always
72
146
  * resizes the window, and the DOM has no other signal for the manual work-area maximize. Failures
@@ -1,6 +1,12 @@
1
1
  import { useEffect, useRef, useState } from 'react';
2
+ import { useShenoraEvent } from './hooks.js';
2
3
  import { debounce } from './internal.js';
3
4
  import { BaseModuleService } from './moduleService.js';
5
+ /** The host's `SHENORA.WINDOW` event names, pinned against it by `WireMirrorTests`. */
6
+ export const WindowEventTypes = {
7
+ /** A {@link CaptionButtonState}, sent to the window's own page. See {@link useCaptionButtonState}. */
8
+ CaptionButtonState: 'CAPTION_BUTTON_STATE',
9
+ };
4
10
  /**
5
11
  * Typed client for the host's `SHENORA.WINDOW` module (`WindowCommandModule` in Shenora.Windows) —
6
12
  * drive the frameless window's chrome from the page: chrome buttons call
@@ -35,6 +41,15 @@ export class WindowCommands extends BaseModuleService {
35
41
  startResize(edge = 'top') {
36
42
  return this.send('START_RESIZE', { payload: { edge } });
37
43
  }
44
+ /**
45
+ * Open the window's system menu at the pointer, as a right click on a real caption does. Call from the header's
46
+ * `onContextMenu`, and `preventDefault()` there so the browser's own menu does not open too. It resolves as the
47
+ * menu opens, not when it closes. A Chromium page's `-webkit-app-region: drag` area opens the menu on a right click
48
+ * without it.
49
+ */
50
+ showSystemMenu() {
51
+ return this.send('SHOW_SYSTEM_MENU');
52
+ }
38
53
  /** Resync the native chrome to the app theme (host `WindowCommandOptions.ApplyTheme`). */
39
54
  setTheme(dark) {
40
55
  return this.send('SET_THEME', { payload: { dark } });
@@ -44,9 +59,15 @@ export class WindowCommands extends BaseModuleService {
44
59
  * chiefly so Windows 11 offers **Snap Layouts** on the maximize button.
45
60
  *
46
61
  * ⚠ The host then takes over CLICKS in those rects and performs minimize/maximize/close itself, so
47
- * your `onClick` handlers stop firing there. CSS `:hover` stops firing too — subscribe to the host's
48
- * caption-button state to render hot/pressed, which is also the only way to stay hot while the
49
- * pointer is over the snap flyout, a different window.
62
+ * your `onClick` handlers stop firing there. CSS `:hover` stops firing too: render hot and pressed from
63
+ * {@link useCaptionButtonState}.
64
+ *
65
+ * ⚠ The Chromium shell forgets the rects when a new document loads, so send them from the page on
66
+ * every load as well as on every layout change.
67
+ *
68
+ * On either shell the window can paint the buttons itself instead (`NativeCaptionButtons` on
69
+ * `OptimizedFormOptions` or `ChromiumWindowOptions`): then the page reserves the rects and draws nothing
70
+ * there. The Chromium shell's painted buttons follow {@link WindowCommands.setTheme}.
50
71
  *
51
72
  * ⚠ Re-send on every layout change: the rectangles are a snapshot, and a stale one moves the
52
73
  * hit-test off the button the user can see. Pass an empty array to hand the pixels back to the page.
@@ -54,6 +75,30 @@ export class WindowCommands extends BaseModuleService {
54
75
  setCaptionButtons(buttons) {
55
76
  return this.send('SET_CAPTION_BUTTONS', { payload: { buttons } });
56
77
  }
78
+ /**
79
+ * Colour the caption buttons the window paints, to match the page's title bar; send them again when the page's
80
+ * theme changes. `null` goes back to the default: on the Chromium shell the colours of {@link WindowCommands.setTheme},
81
+ * on the WebView2 shell a fallback from the form's own colour (it replaces `OptimizedForm.CaptionButtonColors`).
82
+ * The row's height is the page's: the rects sent to {@link WindowCommands.setCaptionButtons}.
83
+ *
84
+ * ⚠ Rejects with `NO_ROUTE` where the window does not paint its buttons, and with `INVALID_PAYLOAD_VALUE` for a
85
+ * colour that is not CSS hex.
86
+ */
87
+ setCaptionButtonColors(colors) {
88
+ return this.send('SET_CAPTION_BUTTON_COLORS', { payload: colors ? { colors } : {} });
89
+ }
90
+ }
91
+ /**
92
+ * Which caption button to render hot or pressed, for buttons registered with
93
+ * {@link WindowCommands.setCaptionButtons}, where CSS `:hover` no longer fires. The Chromium shell sends it to
94
+ * the window's own page as the pointer moves. The WebView2 shell sends nothing by itself: with
95
+ * `NativeCaptionButtons` it paints the caption buttons itself, and an app drawing its own there can emit this
96
+ * event from `OptimizedForm.CaptionButtonStateChanged`.
97
+ */
98
+ export function useCaptionButtonState(options = {}) {
99
+ const [state, setState] = useState({});
100
+ useShenoraEvent('SHENORA.WINDOW', WindowEventTypes.CaptionButtonState, (payload) => setState(payload ?? {}), { bus: options.bus });
101
+ return state;
57
102
  }
58
103
  /**
59
104
  * The authoritative maximize state, re-queried when a resize SETTLES — a maximize/restore always
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Hold the window at an orientation. Mirrors `Shenora.Modules.Platform.WindowOrientationModule`, pinned
3
+ * by `WireMirrorTests`.
4
+ *
5
+ * 🔴 **Try `screen.orientation.lock()` first — and know why it usually will not do.** The web API is
6
+ * honoured only while the document is FULLSCREEN, so a page can hold an orientation only by taking over
7
+ * the display; and WKWebView does not implement it at all. This route has neither limitation, because the
8
+ * host asks the platform directly. The common shape — portrait everywhere EXCEPT a media viewer — is
9
+ * exactly the one the web API cannot express.
10
+ *
11
+ * ⚠ **Branch on the `windowOrientation` capability**, which only a shell that can really hold an
12
+ * orientation advertises. Calling where it is absent is refused with `CAPABILITY_NOT_SUPPORTED` rather
13
+ * than silently ignored — but a page that checks first never shows a control that cannot work.
14
+ *
15
+ * ⚠ **There is no "what is it now".** The page already knows: `screen.orientation.type`, or a CSS media
16
+ * query that re-renders for it. An IPC answer would be the same fact, later.
17
+ */
18
+ import type { ShenoraBridge } from './bridge.js';
19
+ import { BaseModuleService } from './moduleService.js';
20
+ /** The orientations a window can be held at — the host's `WindowOrientation` enum, serialized. */
21
+ export type WindowOrientationKind = 'portrait' | 'landscape';
22
+ interface WindowOrientationRequests {
23
+ LOCK: {
24
+ orientation: WindowOrientationKind;
25
+ };
26
+ UNLOCK: void;
27
+ }
28
+ /**
29
+ * Typed client for the host's `SHENORA.ORIENTATION` module. Two calls and no state: the app decides
30
+ * WHEN, the kit never rotates anything by itself.
31
+ *
32
+ * ```tsx
33
+ * const orientation = new WindowOrientation();
34
+ * useEffect(() => {
35
+ * if (!capabilities.has('windowOrientation')) return;
36
+ * orientation.lock('landscape'); // entering the viewer
37
+ * return () => { void orientation.unlock(); }; // leaving it
38
+ * }, []);
39
+ * ```
40
+ */
41
+ export declare class WindowOrientation extends BaseModuleService<WindowOrientationRequests> {
42
+ constructor(bridge?: ShenoraBridge);
43
+ /** Hold the window at `orientation` until {@link unlock}. Idempotent; a second lock replaces the first. */
44
+ lock(orientation: WindowOrientationKind): Promise<void>;
45
+ /** Let the platform choose again — whatever the device's own rotation setting says. Idempotent. */
46
+ unlock(): Promise<void>;
47
+ }
48
+ export {};
@@ -0,0 +1,27 @@
1
+ import { BaseModuleService } from './moduleService.js';
2
+ /**
3
+ * Typed client for the host's `SHENORA.ORIENTATION` module. Two calls and no state: the app decides
4
+ * WHEN, the kit never rotates anything by itself.
5
+ *
6
+ * ```tsx
7
+ * const orientation = new WindowOrientation();
8
+ * useEffect(() => {
9
+ * if (!capabilities.has('windowOrientation')) return;
10
+ * orientation.lock('landscape'); // entering the viewer
11
+ * return () => { void orientation.unlock(); }; // leaving it
12
+ * }, []);
13
+ * ```
14
+ */
15
+ export class WindowOrientation extends BaseModuleService {
16
+ constructor(bridge) {
17
+ super('SHENORA.ORIENTATION', bridge);
18
+ }
19
+ /** Hold the window at `orientation` until {@link unlock}. Idempotent; a second lock replaces the first. */
20
+ lock(orientation) {
21
+ return this.send('LOCK', { payload: { orientation } });
22
+ }
23
+ /** Let the platform choose again — whatever the device's own rotation setting says. Idempotent. */
24
+ unlock() {
25
+ return this.send('UNLOCK');
26
+ }
27
+ }