@shenora/react 0.9.1 → 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.
@@ -0,0 +1,248 @@
1
+ /**
2
+ * The page half of the host's segment route (D71 piece 4) — reading the manifest it serves, choosing the
3
+ * MediaSource this browser actually has, and deciding what to fetch next.
4
+ *
5
+ * 🔴 **Everything in this module is PURE, and the split is deliberate.** The DECISIONS live here, where a
6
+ * test pins them exactly, and `segmentBinder.ts` holds the imperative half — creating a `SourceBuffer`,
7
+ * appending bytes, listening to element events. That is the same division `SegmentGrid` makes on the host
8
+ * side, for the same reason.
9
+ *
10
+ * ⚠ **"The imperative half cannot be verified anywhere this repo runs" was true and is no longer.** jsdom
11
+ * still has no MediaSource and both real implementations still live on devices — but the binder takes its
12
+ * source and its fetch as PARAMETERS, so a fake drives every branch of it. What a fake cannot say is
13
+ * whether a real implementation accepts the bytes; that is measured on hardware and recorded in
14
+ * `docs/design/media.md`.
15
+ */
16
+ /**
17
+ * Parse the subset of HLS the host emits. Deliberately NOT a general playlist parser: this reads what
18
+ * `SegmentStream` writes, and anything it does not understand is ignored rather than guessed at.
19
+ *
20
+ * ⚠ Unknown tags are skipped silently BY DESIGN — a playlist gains tags over time and a parser that threw
21
+ * on the first one it had not met would break on a host newer than the page.
22
+ */
23
+ export function parseManifest(text) {
24
+ const segments = [];
25
+ let initUri = null;
26
+ let targetSeconds = 0;
27
+ let pending = null;
28
+ for (const raw of text.split('\n')) {
29
+ const line = raw.trim();
30
+ if (line.length === 0)
31
+ continue;
32
+ if (line.startsWith('#EXT-X-MAP:')) {
33
+ // URI="init.mp4" — quoted per the spec, and the quotes are not optional there.
34
+ initUri = /URI="([^"]*)"/.exec(line)?.[1] ?? null;
35
+ continue;
36
+ }
37
+ if (line.startsWith('#EXT-X-TARGETDURATION:')) {
38
+ targetSeconds = Number(line.slice('#EXT-X-TARGETDURATION:'.length)) || 0;
39
+ continue;
40
+ }
41
+ if (line.startsWith('#EXTINF:')) {
42
+ // `#EXTINF:6.000,` — the trailing comma introduces an optional title.
43
+ pending = Number(line.slice('#EXTINF:'.length).split(',')[0]) || 0;
44
+ continue;
45
+ }
46
+ if (line.startsWith('#'))
47
+ continue;
48
+ // A bare line is a URI, and it belongs to the EXTINF above it.
49
+ segments.push({ uri: line, seconds: pending ?? 0 });
50
+ pending = null;
51
+ }
52
+ return { initUri, targetSeconds, segments };
53
+ }
54
+ /**
55
+ * Which MediaSource to use — **`ManagedMediaSource` first where it exists**.
56
+ *
57
+ * 🔴 **The order is the decision, and it is not "newest wins".** iOS on iPhone has only
58
+ * `ManagedMediaSource`; Android has only `MediaSource`. Where BOTH exist the managed one is still preferred,
59
+ * because it is the one that tells the page when the platform actually wants data — a page that streams
60
+ * regardless is what the managed variant was introduced to stop.
61
+ *
62
+ * ✅ **Measured rather than assumed** (iPhone 16 Pro simulator, iOS 26, 2026-08-14): `window.MediaSource` is
63
+ * `false` there and `ManagedMediaSource` is `true`. So on iOS this is not a preference at all — **a binder
64
+ * that only knows `window.MediaSource` does nothing**, and the naming here is what makes one bundle work on
65
+ * both shells.
66
+ *
67
+ * ⚠ **`'managed'` carries an obligation, which is why this returns a KIND rather than just a constructor.**
68
+ * A managed source only wants data between its `startstreaming` and `endstreaming` events, and fetching
69
+ * outside that window is the thing it exists to prevent. A caller that ignores the kind has not merely
70
+ * missed an optimisation.
71
+ */
72
+ export function pickMediaSource(globals) {
73
+ if (typeof globals.ManagedMediaSource === 'function')
74
+ return 'managed';
75
+ if (typeof globals.MediaSource === 'function')
76
+ return 'standard';
77
+ return 'none';
78
+ }
79
+ /**
80
+ * The next segment index to fetch, or null for "nothing right now".
81
+ *
82
+ * 🔴 **Every branch here is a decision whose failure is SILENT in a browser**, which is why it is a pure
83
+ * function with a test rather than an `if` inside an event handler:
84
+ *
85
+ * - **Not streaming → null.** A managed source that is fetched while it said stop is the exact misuse it
86
+ * exists to detect, and on iOS the penalty is the platform tearing the source down.
87
+ * - **Enough buffered → null.** Fetching further ahead than the policy asks does not make playback smoother;
88
+ * it fills a quota, and a `QuotaExceededError` on append arrives as a stall with no obvious cause.
89
+ * - **Otherwise the segment CONTAINING `currentTime`, or the first unappended one after it.** Starting from
90
+ * "the next index after the last one appended" instead is what breaks seeking: after a jump the last
91
+ * append is nowhere near where the user is now.
92
+ */
93
+ export function nextSegment(state, policy) {
94
+ if (!state.streaming)
95
+ return null;
96
+ if (state.bufferedAhead >= policy.targetAheadSeconds)
97
+ return null;
98
+ if (policy.segments.length === 0)
99
+ return null;
100
+ // Walk the playlist's own durations rather than assuming a fixed grid: the LAST segment is short, so
101
+ // dividing by the target duration puts the tail index past the end.
102
+ let at = 0;
103
+ let index = 0;
104
+ for (; index < policy.segments.length; index++) {
105
+ const end = at + policy.segments[index].seconds;
106
+ if (state.currentTime < end)
107
+ break;
108
+ at = end;
109
+ }
110
+ for (let i = Math.min(index, policy.segments.length - 1); i < policy.segments.length; i++) {
111
+ if (!state.appended.has(i))
112
+ return i;
113
+ }
114
+ return null;
115
+ }
116
+ /**
117
+ * The MIME type to open a `SourceBuffer` with.
118
+ *
119
+ * ⚠ **The codecs parameter is REQUIRED, not decorative.** `addSourceBuffer('video/mp4')` throws
120
+ * `NotSupportedError` on every implementation — the buffer has to know what it is about to be fed before the
121
+ * init segment arrives. The default is H.264 High 4.0 plus AAC-LC.
122
+ *
123
+ * 🔴 **And the default is a DEFAULT rather than a guarantee, because the host copies whatever the source
124
+ * already holds** (D76): a segment's picture keeps the profile and level the original encoder chose, and an
125
+ * HEVC source arrives as `hvc1` and not `avc1` at all. The family is what an implementation actually checks,
126
+ * so an H.264 source of any profile plays through the default — **an HEVC one needs its own string**, e.g.
127
+ * `segmentMimeType('hvc1.1.6.L93.B0,mp4a.40.2')`.
128
+ */
129
+ export function segmentMimeType(codecs = 'avc1.640028,mp4a.40.2') {
130
+ return `video/mp4; codecs="${codecs}"`;
131
+ }
132
+ /**
133
+ * Read the codecs parameter out of an initialisation segment, so the `SourceBuffer` is opened for the
134
+ * tracks it will actually be fed.
135
+ *
136
+ * 🔴 **The TRACK SET is the part that must be right, and getting it wrong is fatal rather than
137
+ * degraded.** Measured against Chromium 151 on the kit's own segments: a video-only init segment
138
+ * appended to a buffer opened with the two-track default (`avc1.640028,mp4a.40.2`) fails the FIRST
139
+ * append and plays nothing at all — while the same bytes with `avc1.640015` play. A source with no
140
+ * soundtrack is ordinary, so a fixed default cannot serve both.
141
+ *
142
+ * ⚠ **The profile and level, by contrast, are barely checked.** The same measurement fed High 2.1
143
+ * content to a buffer opened as Baseline 3.0 (`avc1.42E01E`) and it played. That is why this returns a
144
+ * precise string when the configuration is there to read and a family default when it is not: precision
145
+ * where it is free, and never a guess about which tracks exist.
146
+ *
147
+ * @param init The bytes of the `#EXT-X-MAP` segment.
148
+ * @returns A codecs string for {@link segmentMimeType}, or null when no track could be read — the caller
149
+ * should treat that as "do not open a SourceBuffer", not as "use the default".
150
+ */
151
+ export function codecsFromInitSegment(init) {
152
+ const view = new DataView(init.buffer, init.byteOffset, init.byteLength);
153
+ const codecs = [];
154
+ /** Bytes are read through the view rather than by index: `noUncheckedIndexedAccess` types `init[i]`
155
+ * as possibly-undefined, and a `!` on every read would hide the one that IS out of range. */
156
+ const u8 = (at) => view.getUint8(at);
157
+ const fourcc = (at) => String.fromCharCode(u8(at), u8(at + 1), u8(at + 2), u8(at + 3));
158
+ const hex2 = (n) => n.toString(16).padStart(2, '0');
159
+ /** Walk the direct children of [start, end), calling `visit` with (type, contentStart, contentEnd). */
160
+ const children = (start, end, visit) => {
161
+ let at = start;
162
+ while (at + 8 <= end) {
163
+ let size = view.getUint32(at);
164
+ let header = 8;
165
+ if (size === 1) {
166
+ if (at + 16 > end)
167
+ return;
168
+ size = Number(view.getBigUint64(at + 8));
169
+ header = 16;
170
+ }
171
+ if (size === 0)
172
+ size = end - at;
173
+ if (size < header || at + size > end)
174
+ return;
175
+ visit(fourcc(at + 4), at + header, at + size);
176
+ at += size;
177
+ }
178
+ };
179
+ /** The `avcC`/`hvcC`/`esds` inside a sample entry, whose own fields come first. */
180
+ const configuration = (format, start, end) => {
181
+ // Measured against the host's own init segment: an `avc1` entry holds 132 bytes of content and a
182
+ // 54-byte `avcC`, so the child boxes start 78 in — the 8-byte SampleEntry base (6 reserved + a data
183
+ // reference index) plus VisualSampleEntry's 70. An `mp4a` entry is that base plus 20.
184
+ const visual = format === 'avc1' || format === 'avc3' || format === 'hvc1' || format === 'hev1';
185
+ const at = start + (visual ? 78 : 28);
186
+ let derived = '';
187
+ children(at, end, (type, s) => {
188
+ if ((type === 'avcC') && s + 4 <= end) {
189
+ // configurationVersion, AVCProfileIndication, profile_compatibility, AVCLevelIndication
190
+ derived = `${format}.${hex2(u8(s + 1))}${hex2(u8(s + 2))}${hex2(u8(s + 3))}`;
191
+ }
192
+ else if (type === 'hvcC' && s + 13 <= end) {
193
+ // ISO 14496-15 §8.3.3: profile_space/tier/idc, then 4 compatibility bytes, 6 constraint bytes,
194
+ // then the level. Rendered in the form Chromium and Safari both accept.
195
+ const space = (u8(s + 1) >> 6) & 0x3;
196
+ const tier = (u8(s + 1) >> 5) & 0x1;
197
+ const idc = u8(s + 1) & 0x1f;
198
+ const compat = view.getUint32(s + 2);
199
+ const level = u8(s + 12);
200
+ const spaces = ['', 'A', 'B', 'C'][space];
201
+ // The compatibility flags travel REVERSED, as a bit string, per the spec's C.1.
202
+ let reversed = 0;
203
+ for (let bit = 0; bit < 32; bit++)
204
+ reversed |= ((compat >>> bit) & 1) << (31 - bit);
205
+ derived = `${format}.${spaces}${idc}.${(reversed >>> 0).toString(16).toUpperCase()}.` +
206
+ `${tier ? 'H' : 'L'}${level}`;
207
+ }
208
+ else if (type === 'esds' && s + 21 <= end) {
209
+ // The object type indication, then the audio object type from the DecoderSpecificInfo's first
210
+ // 5 bits. Only AAC is ever copied here, so the walk stays a search rather than a full parser.
211
+ for (let p = s; p + 2 < end; p++) {
212
+ if (u8(p) === 0x04 && u8(p + 2) === 0x40) {
213
+ derived = `mp4a.40.${u8(p + 3) || 2}`;
214
+ break;
215
+ }
216
+ }
217
+ if (!derived)
218
+ derived = 'mp4a.40.2';
219
+ }
220
+ });
221
+ if (derived)
222
+ return derived;
223
+ // No configuration to read: name the FAMILY, which is what an implementation actually checks.
224
+ return format === 'mp4a' ? 'mp4a.40.2' : format;
225
+ };
226
+ const stsd = (start, end) => {
227
+ // FullBox header (4) + entry_count (4), then the entries.
228
+ let at = start + 8;
229
+ while (at + 8 <= end) {
230
+ const size = view.getUint32(at);
231
+ if (size < 8 || at + size > end)
232
+ return;
233
+ codecs.push(configuration(fourcc(at + 4), at + 8, at + size));
234
+ at += size;
235
+ }
236
+ };
237
+ const descend = (start, end) => {
238
+ children(start, end, (type, s, e) => {
239
+ if (type === 'stsd')
240
+ stsd(s, e);
241
+ else if (type === 'moov' || type === 'trak' || type === 'mdia' || type === 'minf' || type === 'stbl') {
242
+ descend(s, e);
243
+ }
244
+ });
245
+ };
246
+ descend(0, init.byteLength);
247
+ return codecs.length > 0 ? codecs.join(',') : null;
248
+ }
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
  *
@@ -37,6 +53,19 @@ export declare const IpcErrorCodes: {
37
53
  * one failure a UI should stay silent about. Previously indistinguishable from `UNKNOWN_ERROR`.
38
54
  */
39
55
  readonly operationCancelled: "OPERATION_CANCELLED";
56
+ /**
57
+ * The shell has NO EXPRESSION of what was asked for — not a fault, and not something a retry fixes.
58
+ * Parameters: `capability` (a {@link ShellCapabilities} value).
59
+ *
60
+ * Treat it like `operationCancelled`: do not show a fault. The right response is to hide the control,
61
+ * because the capability is absent by design on that platform (a folder picker on a phone, for
62
+ * instance) rather than broken.
63
+ *
64
+ * ⚠ A page should not normally NEED this. The ready handshake advertises `ShellInfo.capabilities`
65
+ * precisely so one bundle can decide BEFORE it asks — `useFileDialogs().canPickFolder` is the
66
+ * intended path. This is the honest answer when a page asks anyway.
67
+ */
68
+ readonly capabilityNotSupported: "CAPABILITY_NOT_SUPPORTED";
40
69
  /** Client-only: the request timed out waiting for a response. */
41
70
  readonly timeout: "TIMEOUT";
42
71
  /** Client-only: no transport is available and no fallback was configured. */
@@ -105,6 +134,15 @@ export declare const ShellCapabilities: {
105
134
  readonly savePicker: "savePicker";
106
135
  readonly secondaryWindows: "secondaryWindows";
107
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";
108
146
  /**
109
147
  * The host can serve LOCAL FILES to this page — media, images, documents, exports — through its resource
110
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
  *
@@ -37,6 +53,19 @@ export const IpcErrorCodes = {
37
53
  * one failure a UI should stay silent about. Previously indistinguishable from `UNKNOWN_ERROR`.
38
54
  */
39
55
  operationCancelled: 'OPERATION_CANCELLED',
56
+ /**
57
+ * The shell has NO EXPRESSION of what was asked for — not a fault, and not something a retry fixes.
58
+ * Parameters: `capability` (a {@link ShellCapabilities} value).
59
+ *
60
+ * Treat it like `operationCancelled`: do not show a fault. The right response is to hide the control,
61
+ * because the capability is absent by design on that platform (a folder picker on a phone, for
62
+ * instance) rather than broken.
63
+ *
64
+ * ⚠ A page should not normally NEED this. The ready handshake advertises `ShellInfo.capabilities`
65
+ * precisely so one bundle can decide BEFORE it asks — `useFileDialogs().canPickFolder` is the
66
+ * intended path. This is the honest answer when a page asks anyway.
67
+ */
68
+ capabilityNotSupported: 'CAPABILITY_NOT_SUPPORTED',
40
69
  /** Client-only: the request timed out waiting for a response. */
41
70
  timeout: 'TIMEOUT',
42
71
  /** Client-only: no transport is available and no fallback was configured. */
@@ -63,6 +92,15 @@ export const ShellCapabilities = {
63
92
  savePicker: 'savePicker',
64
93
  secondaryWindows: 'secondaryWindows',
65
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',
66
104
  /**
67
105
  * The host can serve LOCAL FILES to this page — media, images, documents, exports — through its resource
68
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 —