@shenora/react 0.1.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.
@@ -0,0 +1,57 @@
1
+ import { getBridge } from './bridge.js';
2
+ /**
3
+ * Base class for typed module services, ported from the primary desktop sibling: each backend
4
+ * module gets one service subclass that binds the module name once and exposes app-typed
5
+ * methods over {@link send}. Bind `TRequests` to the module's request map
6
+ * (`{ [type]: payloadType }`) for compile-time payload checking:
7
+ *
8
+ * ```ts
9
+ * interface NoteRequests { GET_ALL: void; ADD: { title: string } }
10
+ * class NoteService extends BaseModuleService<NoteRequests> {
11
+ * constructor() { super('NOTES'); }
12
+ * getAll() { return this.send<Note[]>('GET_ALL'); }
13
+ * add(title: string) { return this.send<Note>('ADD', { payload: { title } }); }
14
+ * }
15
+ * ```
16
+ *
17
+ * DEVIATION from the source: its boolean/array/optional convenience wrappers were pure casts
18
+ * around the same call — the response generic already expresses them, so they're gone.
19
+ *
20
+ * `TRequests extends object`, NOT `extends Record<string, unknown>` (P5.5 H6). The stricter bound was
21
+ * unsatisfiable by a plain `interface` — interfaces get no implicit index signature — so the example
22
+ * above and the README's snippet both failed with TS2344, on the first line an adopter copies. And
23
+ * satisfying it the way the kit's own `windowCommands.ts` did (`interface X extends Record<string,
24
+ * unknown>`) widened `keyof TRequests & string` back to `string`, so a mistyped request type compiled
25
+ * and every payload collapsed to `unknown` — the flagship typed-service feature checking nothing at all.
26
+ */
27
+ export class BaseModuleService {
28
+ constructor(
29
+ /** The backend module this service fronts (e.g. `"NOTES"`). */
30
+ module,
31
+ /**
32
+ * The bridge to speak over. Omit to use the shared default bridge, resolved PER CALL — see
33
+ * {@link bridge}.
34
+ */
35
+ explicitBridge) {
36
+ this.module = module;
37
+ this.explicitBridge = explicitBridge;
38
+ }
39
+ /**
40
+ * The bridge this service speaks over — resolved on every access, never captured.
41
+ *
42
+ * This used to be a constructor default (`bridge: ShenoraBridge = getBridge()`), which is evaluated
43
+ * at CONSTRUCTION: a service built before `configureBridge()` captured the old default, and
44
+ * `configureBridge` DISPOSES the bridge it replaces — so every later call from that service
45
+ * rejected with "Bridge disposed" for the rest of the session, with nothing to suggest why (P5.5
46
+ * H2). Module services are commonly module-level singletons, so constructing one before the app's
47
+ * startup configuration ran is the normal case, not an edge case. `useDropZone` already resolved
48
+ * lazily for exactly this reason; this matches it.
49
+ */
50
+ get bridge() {
51
+ return this.explicitBridge ?? getBridge();
52
+ }
53
+ /** Send one typed request to this module and await the typed response data. */
54
+ send(type, options = {}) {
55
+ return this.bridge.invoke(this.module, type, options);
56
+ }
57
+ }
@@ -0,0 +1,102 @@
1
+ import { type ShenoraBridge, type PostOptions } from './bridge.js';
2
+ import { type ShenoraEventBus } from './eventBus.js';
3
+ import type { EventMessage } from './types.js';
4
+ /** The IPC handles a store's `actions` are built over. */
5
+ export interface ShenoraStoreIo {
6
+ /** Fire-and-forget send to this store's module. Returns the request id. */
7
+ post: <TPayload = unknown>(type: string, options?: PostOptions<TPayload>) => string;
8
+ /** Correlated request to this store's module — for calls that are quick AND UI-thread-safe. */
9
+ invoke: <TData = unknown, TPayload = unknown>(type: string, options?: {
10
+ payload?: TPayload;
11
+ timeoutMs?: number;
12
+ }) => Promise<TData>;
13
+ }
14
+ /** How a store loads the state that already exists before anyone was watching. */
15
+ export interface ShenoraStoreSnapshot<TState> {
16
+ /** The request type to `invoke` on this store's module. */
17
+ type: string;
18
+ payload?: unknown;
19
+ /** Fold the response into state. */
20
+ apply: (state: TState, data: unknown) => TState;
21
+ }
22
+ /** Inputs for {@link createShenoraStore}. */
23
+ export interface ShenoraStoreOptions<TState, TActions> {
24
+ /** State before anything has arrived. */
25
+ initial: TState;
26
+ /**
27
+ * Load the CURRENT state when the first component subscribes.
28
+ *
29
+ * Not optional in spirit, even though it is in the type: a component that mounts while work is
30
+ * already in flight has MISSED the events, and a stream cannot be replayed. Snapshot-then-deltas
31
+ * is the contract; deltas alone silently work only for whoever was watching from the start.
32
+ */
33
+ snapshot?: ShenoraStoreSnapshot<TState>;
34
+ /** Event type (within this module) → PURE reducer over state. */
35
+ on?: Record<string, (state: TState, payload: never, event: EventMessage) => TState>;
36
+ /** Fire-and-forget senders / requests, exposed on the returned hook. */
37
+ actions?: (io: ShenoraStoreIo) => TActions;
38
+ /** Optional app-defined routing scope, applied to both the subscriptions and the sends. */
39
+ scope?: string;
40
+ /** Test/multi-transport seams. Default: the shared bridge and event bus. */
41
+ bridge?: ShenoraBridge;
42
+ bus?: ShenoraEventBus;
43
+ /** Where a reducer or snapshot failure is reported. Default: `console.error`. */
44
+ onError?: (error: unknown, context: {
45
+ module: string;
46
+ type: string;
47
+ }) => void;
48
+ }
49
+ /** What {@link createShenoraStore} returns: a hook, plus the store handles for non-React callers. */
50
+ export interface ShenoraStore<TState, TActions> {
51
+ /** Subscribe this component. With no selector you get the whole state. */
52
+ (): TState;
53
+ <TSelected>(selector: (state: TState) => TSelected): TSelected;
54
+ /** Current state without subscribing (event handlers, actions, tests). */
55
+ getState: () => TState;
56
+ /** Subscribe outside React; returns an unsubscribe. */
57
+ subscribe: (listener: () => void) => () => void;
58
+ /** The declared actions. */
59
+ actions: TActions;
60
+ /** Test seam: drop state back to `initial` and forget that the snapshot ran. */
61
+ reset: () => void;
62
+ }
63
+ /**
64
+ * A store fed by one module's host event stream, shared by every component that reads it.
65
+ *
66
+ * This is the shape a desktop app needs and the one three sibling apps each hand-built before it
67
+ * existed here — see `docs/2026-07-31-shenora-oneway-ipc-design.md` §5 for the survey. It exists
68
+ * because status- and progress-driven UI is inherently MANY-WATCHERS: a full panel and a compact
69
+ * progress strip want the same live state, and without a shared store each re-implements the wiring,
70
+ * each opens its own subscription, and each starts empty.
71
+ *
72
+ * What it guarantees:
73
+ * - **One subscription per event type, however many components read it.** Mounting N components does
74
+ * not open N subscriptions; unmounting the last one tears them down.
75
+ * - **A late mounter sees current state**, via {@link ShenoraStoreOptions.snapshot} on the first
76
+ * subscription. This is the part that cannot be retrofitted by subscribing harder.
77
+ * - **No state library.** Built on React's `useSyncExternalStore`, which exists for exactly this and
78
+ * is tearing-free under concurrent rendering. The kit imposes no store dependency (D13's spirit —
79
+ * apps bring their own); every sibling reached for the same one, and baking that in would have been
80
+ * solving their stack rather than their problem.
81
+ * - **Reducers are PURE and isolated**: they take state + payload and return state, so a store is
82
+ * testable with no bridge, and a throwing reducer is reported rather than corrupting shared state.
83
+ *
84
+ * Headless (D13/D21): the kit ships the MECHANISM. What an operation is — its phases, its progress
85
+ * shape, whether it queues — stays in the app; there is deliberately no job/queue/progress type here.
86
+ *
87
+ * @example
88
+ * const useDeploy = createShenoraStore('DEPLOY', {
89
+ * initial: { status: 'idle' as const, lines: [] as string[] },
90
+ * snapshot: { type: 'GET_STATE', apply: (s, d) => ({ ...s, ...(d as object) }) },
91
+ * on: {
92
+ * PROGRESS: (s, p: { line: string }) => ({ ...s, lines: [...s.lines, p.line] }),
93
+ * ENDED: (s, p: { ok: boolean }) => ({ ...s, status: p.ok ? 'done' : 'failed' }),
94
+ * },
95
+ * actions: ({ post }) => ({ start: (cfg: unknown) => post('START', { payload: cfg }) }),
96
+ * });
97
+ *
98
+ * // in any number of components:
99
+ * const status = useDeploy((s) => s.status);
100
+ * useDeploy.actions.start({ env: 'prod' });
101
+ */
102
+ export declare function createShenoraStore<TState, TActions = Record<string, never>>(module: string, options: ShenoraStoreOptions<TState, TActions>): ShenoraStore<TState, TActions>;
package/dist/store.js ADDED
@@ -0,0 +1,150 @@
1
+ import { useCallback, useDebugValue, useRef, useSyncExternalStore } from 'react';
2
+ import { getBridge } from './bridge.js';
3
+ import { eventBus as defaultEventBus } from './eventBus.js';
4
+ /**
5
+ * A store fed by one module's host event stream, shared by every component that reads it.
6
+ *
7
+ * This is the shape a desktop app needs and the one three sibling apps each hand-built before it
8
+ * existed here — see `docs/2026-07-31-shenora-oneway-ipc-design.md` §5 for the survey. It exists
9
+ * because status- and progress-driven UI is inherently MANY-WATCHERS: a full panel and a compact
10
+ * progress strip want the same live state, and without a shared store each re-implements the wiring,
11
+ * each opens its own subscription, and each starts empty.
12
+ *
13
+ * What it guarantees:
14
+ * - **One subscription per event type, however many components read it.** Mounting N components does
15
+ * not open N subscriptions; unmounting the last one tears them down.
16
+ * - **A late mounter sees current state**, via {@link ShenoraStoreOptions.snapshot} on the first
17
+ * subscription. This is the part that cannot be retrofitted by subscribing harder.
18
+ * - **No state library.** Built on React's `useSyncExternalStore`, which exists for exactly this and
19
+ * is tearing-free under concurrent rendering. The kit imposes no store dependency (D13's spirit —
20
+ * apps bring their own); every sibling reached for the same one, and baking that in would have been
21
+ * solving their stack rather than their problem.
22
+ * - **Reducers are PURE and isolated**: they take state + payload and return state, so a store is
23
+ * testable with no bridge, and a throwing reducer is reported rather than corrupting shared state.
24
+ *
25
+ * Headless (D13/D21): the kit ships the MECHANISM. What an operation is — its phases, its progress
26
+ * shape, whether it queues — stays in the app; there is deliberately no job/queue/progress type here.
27
+ *
28
+ * @example
29
+ * const useDeploy = createShenoraStore('DEPLOY', {
30
+ * initial: { status: 'idle' as const, lines: [] as string[] },
31
+ * snapshot: { type: 'GET_STATE', apply: (s, d) => ({ ...s, ...(d as object) }) },
32
+ * on: {
33
+ * PROGRESS: (s, p: { line: string }) => ({ ...s, lines: [...s.lines, p.line] }),
34
+ * ENDED: (s, p: { ok: boolean }) => ({ ...s, status: p.ok ? 'done' : 'failed' }),
35
+ * },
36
+ * actions: ({ post }) => ({ start: (cfg: unknown) => post('START', { payload: cfg }) }),
37
+ * });
38
+ *
39
+ * // in any number of components:
40
+ * const status = useDeploy((s) => s.status);
41
+ * useDeploy.actions.start({ env: 'prod' });
42
+ */
43
+ export function createShenoraStore(module, options) {
44
+ const { initial, snapshot, on = {}, scope } = options;
45
+ const report = options.onError
46
+ ?? ((error, context) => console.error(`[shenora] store ${context.module}.${context.type} failed:`, error));
47
+ let state = initial;
48
+ let snapshotLoaded = false;
49
+ const listeners = new Set();
50
+ let unsubscribes = [];
51
+ const bridge = () => options.bridge ?? getBridge();
52
+ const bus = () => options.bus ?? defaultEventBus;
53
+ const setState = (next) => {
54
+ if (Object.is(next, state))
55
+ return; // a reducer returning the same state is a no-op, not a render
56
+ state = next;
57
+ for (const listener of [...listeners])
58
+ listener();
59
+ };
60
+ const applyEvent = (type, event) => {
61
+ const reduce = on[type];
62
+ if (!reduce)
63
+ return;
64
+ try {
65
+ setState(reduce(state, event.payload, event));
66
+ }
67
+ catch (error) {
68
+ // 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).
70
+ report(error, { module, type });
71
+ }
72
+ };
73
+ const loadSnapshot = () => {
74
+ if (!snapshot || snapshotLoaded)
75
+ return;
76
+ snapshotLoaded = true; // set BEFORE awaiting: two components mounting in the same tick must not
77
+ // both fire the request (React StrictMode double-invokes effects, which is precisely this case).
78
+ bridge()
79
+ .invoke(module, snapshot.type, { payload: snapshot.payload, scope })
80
+ .then((data) => {
81
+ try {
82
+ setState(snapshot.apply(state, data));
83
+ }
84
+ catch (error) {
85
+ report(error, { module, type: snapshot.type });
86
+ }
87
+ }, (error) => {
88
+ // Allow a later retry: a snapshot that failed because the host was not ready yet should not
89
+ // leave the store permanently empty for the rest of the session.
90
+ snapshotLoaded = false;
91
+ report(error, { module, type: snapshot.type });
92
+ });
93
+ };
94
+ const attach = () => {
95
+ unsubscribes = Object.keys(on).map((type) => bus().subscribe(module, type, (event) => applyEvent(type, event), { scope }));
96
+ loadSnapshot();
97
+ };
98
+ const detach = () => {
99
+ for (const off of unsubscribes)
100
+ off();
101
+ unsubscribes = [];
102
+ };
103
+ const subscribe = (listener) => {
104
+ // ONE subscription per event type for the whole store — the property that makes this worth
105
+ // existing. The first listener attaches; the last one to leave detaches.
106
+ if (listeners.size === 0)
107
+ attach();
108
+ listeners.add(listener);
109
+ return () => {
110
+ listeners.delete(listener);
111
+ if (listeners.size === 0)
112
+ detach();
113
+ };
114
+ };
115
+ const getState = () => state;
116
+ const io = {
117
+ post: (type, postOptions) => bridge().post(module, type, { scope, ...postOptions }),
118
+ invoke: (type, invokeOptions) => bridge().invoke(module, type, { scope, ...invokeOptions }),
119
+ };
120
+ function useStore(selector) {
121
+ // getSnapshot must return a STABLE value for an unchanged store, or React throws
122
+ // "The result of getSnapshot should be cached" and can loop. So the selector result is memoized
123
+ // against the state identity: recomputed only when state actually changed.
124
+ const cache = useRef(null);
125
+ const selectorRef = useRef(selector);
126
+ selectorRef.current = selector;
127
+ const getSelected = useCallback(() => {
128
+ const current = getState();
129
+ const select = selectorRef.current;
130
+ if (!select)
131
+ return current;
132
+ if (cache.current === null || !Object.is(cache.current.state, current)) {
133
+ cache.current = { state: current, selected: select(current) };
134
+ }
135
+ return cache.current.selected;
136
+ }, []);
137
+ const value = useSyncExternalStore(subscribe, getSelected, getSelected);
138
+ useDebugValue(value);
139
+ return value;
140
+ }
141
+ const store = useStore;
142
+ store.getState = getState;
143
+ store.subscribe = subscribe;
144
+ store.actions = options.actions ? options.actions(io) : {};
145
+ store.reset = () => {
146
+ snapshotLoaded = false;
147
+ setState(initial);
148
+ };
149
+ return store;
150
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * A message channel the bridge speaks over — the transport-pluggable seam (design D16): WebView2
3
+ * postMessage on desktop today; a WebSocket or a mobile shell's native channel speaks the same
4
+ * envelopes tomorrow. Messages are JSON strings in both directions.
5
+ */
6
+ export interface ShenoraTransport {
7
+ /** Send one serialized envelope to the host. */
8
+ post(message: string): void;
9
+ /** Register a host→client listener; returns the unsubscribe function. */
10
+ subscribe(listener: (message: string) => void): () => void;
11
+ }
12
+ /**
13
+ * True when running inside a WebView2 desktop host (the bridge transport exists).
14
+ * In a plain browser this is false — callers should fall back to browser-only behavior.
15
+ */
16
+ export declare function isShenoraAvailable(): boolean;
17
+ /** The WebView2 postMessage transport, or null outside a WebView2 host. */
18
+ export declare function createWebView2Transport(): ShenoraTransport | null;
@@ -0,0 +1,26 @@
1
+ const webViewWindow = () => typeof window === 'undefined' ? undefined : window;
2
+ /**
3
+ * True when running inside a WebView2 desktop host (the bridge transport exists).
4
+ * In a plain browser this is false — callers should fall back to browser-only behavior.
5
+ */
6
+ export function isShenoraAvailable() {
7
+ return !!webViewWindow()?.chrome?.webview;
8
+ }
9
+ /** The WebView2 postMessage transport, or null outside a WebView2 host. */
10
+ export function createWebView2Transport() {
11
+ const webview = webViewWindow()?.chrome?.webview;
12
+ if (!webview)
13
+ return null;
14
+ return {
15
+ post: (message) => webview.postMessage(message),
16
+ subscribe: (listener) => {
17
+ // The host posts strings (PostWebMessageAsString); anything else on the channel isn't ours.
18
+ const handler = (event) => {
19
+ if (typeof event.data === 'string')
20
+ listener(event.data);
21
+ };
22
+ webview.addEventListener('message', handler);
23
+ return () => webview.removeEventListener('message', handler);
24
+ },
25
+ };
26
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The Shenora IPC wire contract — the TS mirror of the `Shenora.Ipc` C# envelopes (names are
3
+ * pinned on both sides; the host serializes camelCase). Transport-neutral: the same envelopes
4
+ * travel over WebView2 postMessage, a WebSocket, or a mobile shell's channel.
5
+ */
6
+ /** Values of the `category` discriminator on host→client messages. */
7
+ export declare const IpcCategories: {
8
+ /** A response to a client request. */
9
+ readonly ipc: "ipc";
10
+ /** A host-pushed notification batch. */
11
+ readonly notification: "notification";
12
+ };
13
+ /** Reserved wire route: the ready handshake the host bridge intercepts (mirror of the host consts). */
14
+ export declare const HANDSHAKE_MODULE = "SHENORA";
15
+ /** Reserved wire route: the ready handshake type. */
16
+ export declare const HANDSHAKE_TYPE = "READY";
17
+ /**
18
+ * Error codes with framework-reserved meaning (`errors.{code}` is the family i18n-key
19
+ * convention). `timeout` and `noTransport` are CLIENT-side failures — they never come from the
20
+ * host but reject through the same structured shape so error handling stays uniform.
21
+ */
22
+ export declare const IpcErrorCodes: {
23
+ readonly unknownError: "UNKNOWN_ERROR";
24
+ readonly noHandler: "NO_HANDLER";
25
+ /**
26
+ * A scope-routed module was called without a `scope`. Parameters: `module`.
27
+ *
28
+ * This was MISSING here while the host emitted it (P5.5 H6), so a scoped app could not match it by
29
+ * constant and had to hard-code the string — against documentation claiming the two sides mirror
30
+ * name-for-name. The mirror is now enforced by a test rather than by care.
31
+ */
32
+ readonly scopeRequired: "SCOPE_REQUIRED";
33
+ readonly missingPayloadValue: "MISSING_PAYLOAD_VALUE";
34
+ readonly invalidPayloadValue: "INVALID_PAYLOAD_VALUE";
35
+ /**
36
+ * The operation was cancelled — a NORMAL outcome, not a fault. Treat it as "show nothing": it is the
37
+ * one failure a UI should stay silent about. Previously indistinguishable from `UNKNOWN_ERROR`.
38
+ */
39
+ readonly operationCancelled: "OPERATION_CANCELLED";
40
+ /** Client-only: the request timed out waiting for a response. */
41
+ readonly timeout: "TIMEOUT";
42
+ /** Client-only: no transport is available and no fallback was configured. */
43
+ readonly noTransport: "NO_TRANSPORT";
44
+ };
45
+ /**
46
+ * The codes that exist ONLY on the client — they never arrive from the host, but reject through the same
47
+ * structured shape so app error handling stays uniform. Named here so the cross-language mirror check can
48
+ * exclude them by intent rather than by a hard-coded list on the other side.
49
+ */
50
+ export declare const ClientOnlyIpcErrorCodes: readonly string[];
51
+ /** The request envelope a client sends to the host. */
52
+ export interface IpcRequest<TPayload = unknown> {
53
+ /** Correlation id, echoed back on the response. */
54
+ id: string;
55
+ /** Routing: the module the request targets (e.g. `"APP"`). */
56
+ module: string;
57
+ /** Routing: the action within the module (e.g. `"GET_ALL"`). */
58
+ type: string;
59
+ /** Optional app-defined routing scope. */
60
+ scope?: string;
61
+ payload?: TPayload;
62
+ /** ISO-8601 send time. */
63
+ timestamp: string;
64
+ }
65
+ /** The structured error carried by a failed response. `code` is the i18n key (`errors.{code}`). */
66
+ export interface IpcError {
67
+ code: string;
68
+ /** Untranslated fallback message for logs/dev; not for end users. */
69
+ message?: string;
70
+ /** Values interpolated into the translated message. */
71
+ parameters?: Record<string, string>;
72
+ }
73
+ /** The response envelope the host returns for an {@link IpcRequest}. */
74
+ export interface IpcResponse<TData = unknown> {
75
+ category: typeof IpcCategories.ipc;
76
+ /** The request id this responds to. */
77
+ id: string;
78
+ success: boolean;
79
+ data?: TData;
80
+ error?: IpcError;
81
+ }
82
+ /** One host→client event inside an {@link IpcNotificationBatch}. Fire-and-forget. */
83
+ export interface IpcNotification<TPayload = unknown> {
84
+ module: string;
85
+ type: string;
86
+ payload?: TPayload;
87
+ scope?: string;
88
+ }
89
+ /**
90
+ * The host→client push envelope: notifications batched every ~50 ms host-side. Always a batch —
91
+ * a single notification ships as a batch of one; `category` alone discriminates.
92
+ */
93
+ export interface IpcNotificationBatch {
94
+ category: typeof IpcCategories.notification;
95
+ id: string;
96
+ payload: IpcNotification[];
97
+ timestamp: string;
98
+ }
99
+ /**
100
+ * A client-side event on the event bus — an unbundled {@link IpcNotification} (or a locally
101
+ * emitted event; the host-side `EventMessage` additionally carries id/timestamp, which don't
102
+ * cross the wire).
103
+ */
104
+ export interface EventMessage<TPayload = unknown> {
105
+ module: string;
106
+ type: string;
107
+ payload?: TPayload;
108
+ scope?: string;
109
+ }
package/dist/types.js ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The Shenora IPC wire contract — the TS mirror of the `Shenora.Ipc` C# envelopes (names are
3
+ * pinned on both sides; the host serializes camelCase). Transport-neutral: the same envelopes
4
+ * travel over WebView2 postMessage, a WebSocket, or a mobile shell's channel.
5
+ */
6
+ /** Values of the `category` discriminator on host→client messages. */
7
+ export const IpcCategories = {
8
+ /** A response to a client request. */
9
+ ipc: 'ipc',
10
+ /** A host-pushed notification batch. */
11
+ notification: 'notification',
12
+ };
13
+ /** Reserved wire route: the ready handshake the host bridge intercepts (mirror of the host consts). */
14
+ export const HANDSHAKE_MODULE = 'SHENORA';
15
+ /** Reserved wire route: the ready handshake type. */
16
+ export const HANDSHAKE_TYPE = 'READY';
17
+ /**
18
+ * Error codes with framework-reserved meaning (`errors.{code}` is the family i18n-key
19
+ * convention). `timeout` and `noTransport` are CLIENT-side failures — they never come from the
20
+ * host but reject through the same structured shape so error handling stays uniform.
21
+ */
22
+ export const IpcErrorCodes = {
23
+ unknownError: 'UNKNOWN_ERROR',
24
+ noHandler: 'NO_HANDLER',
25
+ /**
26
+ * A scope-routed module was called without a `scope`. Parameters: `module`.
27
+ *
28
+ * This was MISSING here while the host emitted it (P5.5 H6), so a scoped app could not match it by
29
+ * constant and had to hard-code the string — against documentation claiming the two sides mirror
30
+ * name-for-name. The mirror is now enforced by a test rather than by care.
31
+ */
32
+ scopeRequired: 'SCOPE_REQUIRED',
33
+ missingPayloadValue: 'MISSING_PAYLOAD_VALUE',
34
+ invalidPayloadValue: 'INVALID_PAYLOAD_VALUE',
35
+ /**
36
+ * The operation was cancelled — a NORMAL outcome, not a fault. Treat it as "show nothing": it is the
37
+ * one failure a UI should stay silent about. Previously indistinguishable from `UNKNOWN_ERROR`.
38
+ */
39
+ operationCancelled: 'OPERATION_CANCELLED',
40
+ /** Client-only: the request timed out waiting for a response. */
41
+ timeout: 'TIMEOUT',
42
+ /** Client-only: no transport is available and no fallback was configured. */
43
+ noTransport: 'NO_TRANSPORT',
44
+ };
45
+ /**
46
+ * The codes that exist ONLY on the client — they never arrive from the host, but reject through the same
47
+ * structured shape so app error handling stays uniform. Named here so the cross-language mirror check can
48
+ * exclude them by intent rather than by a hard-coded list on the other side.
49
+ */
50
+ export const ClientOnlyIpcErrorCodes = [
51
+ IpcErrorCodes.timeout,
52
+ IpcErrorCodes.noTransport,
53
+ ];
@@ -0,0 +1,56 @@
1
+ import { type RefObject } from 'react';
2
+ import { type ShenoraBridge } from './bridge.js';
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";
6
+ /** A native file drop delivered to a zone. */
7
+ export interface DropZoneFileDrop {
8
+ zoneId: string;
9
+ /** REAL OS paths — the whole point: the DOM's drop events only ever see blob URLs. */
10
+ files: string[];
11
+ /** Drop position in the zone's physical pixels. */
12
+ position: {
13
+ x: number;
14
+ y: number;
15
+ };
16
+ }
17
+ /**
18
+ * Inputs for {@link useDropZone}.
19
+ *
20
+ * No ordering constraint against `notifyReady()`: the host clears zones when a new DOCUMENT starts
21
+ * loading, not on the handshake, so this hook's `REGISTER` cannot be wiped by a reset that arrives
22
+ * after it. (It could, until the reset moved off the handshake — React runs CHILD effects before
23
+ * PARENT effects, which made losing the registration the default outcome rather than bad luck.)
24
+ */
25
+ export interface UseDropZoneOptions {
26
+ /** The element the native overlay tracks. */
27
+ targetRef: RefObject<HTMLElement | null>;
28
+ /** Called with the dropped OS file paths. */
29
+ onDrop: (files: string[], drop: DropZoneFileDrop) => void;
30
+ /** False = zone torn down (same as unmount). Default true. */
31
+ enabled?: boolean;
32
+ /** Stable zone id; default: generated per mount. */
33
+ zoneId?: string;
34
+ /**
35
+ * Class toggled on the element while a file drag hovers the zone. UNSTYLED — headless (D13):
36
+ * the library ships no CSS; style it in the app. Default `"shenora-drop-hover"`.
37
+ */
38
+ dropClassName?: string;
39
+ /** The bridge to speak over. Default: the shared default bridge. */
40
+ bridge?: ShenoraBridge;
41
+ /** The event bus host notifications arrive on. Default: the shared bus. */
42
+ bus?: ShenoraEventBus;
43
+ }
44
+ /**
45
+ * Sync a native drop-zone overlay to a page element, ported from the primary desktop sibling
46
+ * (its fix-history comments kept below): the host positions a transparent WinForms overlay
47
+ * over the element to capture REAL OS file paths — including drags started while the app is in
48
+ * the background. Bounds re-sync (debounced) on resize/scroll/intersection changes; the host
49
+ * converts the CSS rect to physical pixels per-monitor.
50
+ *
51
+ * How the visibility dance works: mouse leaves the element → SHOW (overlay up, ready to catch a
52
+ * drag); mouse enters → the host hides the overlay (hover effects keep working); an inactive
53
+ * window always shows overlays (background drag-drop); while the overlay is visible the host
54
+ * emits DRAG_ENTER/DRAG_LEAVE for CSS feedback.
55
+ */
56
+ export declare function useDropZone(options: UseDropZoneOptions): void;