use-everywhere 0.7.0 → 0.9.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/README.md CHANGED
@@ -142,13 +142,13 @@ per-connection nonce, and source window.
142
142
  - Values must survive structured clone (no functions, DOM nodes); state lives
143
143
  as long as at least one context holds it — nothing is persisted.
144
144
  - Testing is first-class: inject a `MemoryHub` transport to simulate many tabs
145
- in one test. See the [testing guide](https://rxova.github.io/use-everywhere/guides/testing).
145
+ in one test. See the [testing guide](https://rxova.org/packages/use-everywhere/guides/testing).
146
146
 
147
147
  This package re-exports the full framework-agnostic surface of
148
148
  [`@use-everywhere/core`](https://www.npmjs.com/package/@use-everywhere/core),
149
149
  so you never need to install core directly.
150
150
 
151
- 📖 **[Documentation](https://rxova.github.io/use-everywhere/)** — mental
151
+ 📖 **[Documentation](https://rxova.org/packages/use-everywhere/)** — mental
152
152
  model, how sync works, security model, recipes, and generated API reference.
153
153
  Source and demo app: [github.com/rxova/use-everywhere](https://github.com/rxova/use-everywhere)
154
154
 
@@ -5,6 +5,7 @@ import {
5
5
  createChannel,
6
6
  createLeader,
7
7
  createPresence,
8
+ createSharedReducer,
8
9
  createSharedStore,
9
10
  DEFAULT_NAME,
10
11
  NoopTransport
@@ -17,10 +18,17 @@ try {
17
18
  } catch {
18
19
  }
19
20
  var warned = /* @__PURE__ */ new Set();
20
- function devWarn(message) {
21
- if (!inDev || warned.has(message)) return;
22
- warned.add(message);
23
- console.warn(message);
21
+ var DOCS = "https://rxova.org/packages/use-everywhere/errors";
22
+ function diagnostic(code, message) {
23
+ return `[use-everywhere] ${code}: ${message}
24
+ \u2192 ${DOCS}/#${code.toLowerCase()}`;
25
+ }
26
+ function devWarn(code, message) {
27
+ if (!inDev) return;
28
+ const line = diagnostic(code, message);
29
+ if (warned.has(line)) return;
30
+ warned.add(line);
31
+ console.warn(line);
24
32
  }
25
33
  var seenInitials = /* @__PURE__ */ new Map();
26
34
  function warnOnInitialMismatch(storeName, key, initial) {
@@ -34,7 +42,8 @@ function warnOnInitialMismatch(storeName, key, initial) {
34
42
  const comparable = (v) => v === null || typeof v !== "object";
35
43
  if (comparable(first) && comparable(initial) && !Object.is(first, initial)) {
36
44
  devWarn(
37
- `[use-everywhere] useSharedState('${key}') was called with different initial values (${String(first)} and ${String(initial)}). The first registration wins, so the second is ignored. Define the default once \u2014 defineStore, or a shared constant.`
45
+ "UE2001",
46
+ `useSharedState('${key}') was called with different initial values (${String(first)} and ${String(initial)}). The first registration wins, so the second is ignored. Define the default once \u2014 defineStore, or a shared constant.`
38
47
  );
39
48
  }
40
49
  }
@@ -49,6 +58,12 @@ var unsubscribe = () => noop;
49
58
  var INERT = Object.freeze({
50
59
  clientId: SERVER_CLIENT_ID,
51
60
  // store
61
+ //
62
+ // Resolved, unlike the leader's `waitForLeadership` below. A server render
63
+ // has nothing to restore and is documented to render defaults, so `await
64
+ // store.hydrated` must proceed — a never-settling promise would hang the
65
+ // render rather than describe it honestly.
66
+ hydrated: Promise.resolve(),
52
67
  state: EMPTY,
53
68
  getVersions: () => EMPTY,
54
69
  set: noop,
@@ -57,6 +72,8 @@ var INERT = Object.freeze({
57
72
  // store + leader share getSnapshot; presence has getPeers
58
73
  getSnapshot: () => EMPTY,
59
74
  getPeers: () => NO_PEERS,
75
+ // presence
76
+ setMetadata: noop,
60
77
  // leader
61
78
  resign: noop,
62
79
  setEligible: noop,
@@ -75,6 +92,12 @@ var createServerLeader = () => ({
75
92
  getSnapshot: () => NO_LEADER,
76
93
  waitForLeadership: NEVER
77
94
  });
95
+ var createServerReducer = (initial) => ({
96
+ ...INERT,
97
+ getSnapshot: () => initial,
98
+ dispatch: noop,
99
+ pendingCount: () => 0
100
+ });
78
101
  var createServerChannel = (name) => ({ ...INERT, name, post: noop, on: unsubscribe });
79
102
 
80
103
  // src/registry.ts
@@ -95,15 +118,18 @@ function getSharedStore(name = DEFAULT_NAME, scope = "everywhere") {
95
118
  function configSignature(options) {
96
119
  const persist = options?.persist;
97
120
  if (!persist) return "none";
98
- return `persist:${persist.keys?.join(",") ?? "*"}:${persist.debounceMs ?? "default"}`;
121
+ return `persist:${persist.keys?.join(",") ?? "*"}:${persist.debounceMs ?? "default"}:v${persist.version ?? 0}`;
99
122
  }
100
123
  function configureStore(name, scope, options) {
101
124
  const key = `${scope} ${name}`;
102
125
  if (stores.has(key)) {
103
126
  if (configSignature(storeConfig.get(key)) === configSignature(options)) return;
104
- devWarn(
105
- `[use-everywhere] defineStore('${name}') ran after that store was already created, with different options. The live store keeps the configuration it was built with. Move defineStore to module scope, before any component reads the store.`
106
- );
127
+ if (process.env.NODE_ENV !== "production") {
128
+ devWarn(
129
+ "UE2002",
130
+ `defineStore('${name}') ran after that store was already created, with different options. The live store keeps the configuration it was built with. Move defineStore to module scope, before any component reads the store.`
131
+ );
132
+ }
107
133
  return;
108
134
  }
109
135
  storeConfig.set(key, options);
@@ -117,11 +143,12 @@ function getStore(name, scope = "everywhere") {
117
143
  }
118
144
  return store;
119
145
  }
120
- function getPresence(name) {
121
- let presence = presences.get(name);
146
+ function getPresence(name, includeSelf = false) {
147
+ const key = includeSelf ? `self ${name}` : name;
148
+ let presence = presences.get(key);
122
149
  if (!presence) {
123
- presence = isServer() ? createServerPresence() : createPresence(name);
124
- presences.set(name, presence);
150
+ presence = isServer() ? createServerPresence() : createPresence(name, { includeSelf });
151
+ presences.set(key, presence);
125
152
  }
126
153
  return presence;
127
154
  }
@@ -142,26 +169,55 @@ function warnOnLeaderOptionConflict(name, options) {
142
169
  for (const key of ["heartbeatMs", "leaseMs"]) {
143
170
  const requested = options[key];
144
171
  if (requested !== void 0 && requested !== first?.[key]) {
172
+ if (process.env.NODE_ENV !== "production") {
173
+ devWarn(
174
+ "UE2003",
175
+ `leader "${name}": ${key} ignored \u2014 the first useLeader/getLeader call fixes the election timings for this tab.`
176
+ );
177
+ }
178
+ }
179
+ }
180
+ }
181
+ var channelConfig = /* @__PURE__ */ new Map();
182
+ function configureChannel(name, options) {
183
+ if (channels.has(name)) {
184
+ const before = Object.keys(channelConfig.get(name)?.schema ?? {}).sort();
185
+ const after = Object.keys(options.schema ?? {}).sort();
186
+ if (before.join() === after.join()) return;
187
+ if (process.env.NODE_ENV !== "production") {
145
188
  devWarn(
146
- `[use-everywhere] leader "${name}": ${key} ignored \u2014 the first useLeader/getLeader call fixes the election timings for this tab.`
189
+ "UE2004",
190
+ `defineChannel('${name}') ran after that channel was already created, with different options. The live channel keeps the configuration it was built with. Move defineChannel to module scope, before any component sends or receives on it.`
147
191
  );
148
192
  }
193
+ return;
149
194
  }
195
+ channelConfig.set(name, options);
196
+ }
197
+ var reducers = /* @__PURE__ */ new Map();
198
+ function getReducer(name, key, reducer, initial) {
199
+ const id = `${name} ${key}`;
200
+ let existing = reducers.get(id);
201
+ if (!existing) {
202
+ existing = isServer() ? createServerReducer(initial) : createSharedReducer(name, reducer, initial, { key, leader: getLeader(name) });
203
+ reducers.set(id, existing);
204
+ }
205
+ return existing;
150
206
  }
151
207
  function getChannel(name) {
152
208
  let channel = channels.get(name);
153
209
  if (!channel) {
154
- channel = isServer() ? createServerChannel(name) : createChannel(name);
210
+ channel = isServer() ? createServerChannel(name) : createChannel(name, channelConfig.get(name));
155
211
  channels.set(name, channel);
156
212
  }
157
213
  return channel;
158
214
  }
159
215
 
160
216
  // src/use-peers.ts
161
- import { useCallback, useSyncExternalStore } from "react";
217
+ import { useCallback, useEffect, useSyncExternalStore } from "react";
162
218
  var NO_PEERS2 = Object.freeze([]);
163
219
  function usePeers(options) {
164
- const presence = getPresence(options?.name ?? DEFAULT_NAME);
220
+ const presence = getPresence(options?.name ?? DEFAULT_NAME, options?.includeSelf ?? false);
165
221
  return useSyncExternalStore(
166
222
  useCallback((onChange) => presence.subscribe(onChange), [presence]),
167
223
  () => presence.getPeers(),
@@ -180,6 +236,12 @@ function useClientId(options) {
180
236
  () => SERVER_CLIENT_ID
181
237
  );
182
238
  }
239
+ function usePresenceMetadata(metadata, options) {
240
+ const presence = getPresence(options?.name ?? DEFAULT_NAME, options?.includeSelf ?? false);
241
+ useEffect(() => {
242
+ presence.setMetadata(metadata);
243
+ }, [presence, metadata]);
244
+ }
183
245
 
184
246
  export {
185
247
  warnOnInitialMismatch,
@@ -188,7 +250,10 @@ export {
188
250
  configureStore,
189
251
  getStore,
190
252
  getLeader,
253
+ configureChannel,
254
+ getReducer,
191
255
  getChannel,
192
256
  usePeers,
193
- useClientId
257
+ useClientId,
258
+ usePresenceMetadata
194
259
  };
@@ -42,6 +42,12 @@ var unsubscribe = () => noop;
42
42
  var INERT = Object.freeze({
43
43
  clientId: SERVER_CLIENT_ID,
44
44
  // store
45
+ //
46
+ // Resolved, unlike the leader's `waitForLeadership` below. A server render
47
+ // has nothing to restore and is documented to render defaults, so `await
48
+ // store.hydrated` must proceed — a never-settling promise would hang the
49
+ // render rather than describe it honestly.
50
+ hydrated: Promise.resolve(),
45
51
  state: EMPTY,
46
52
  getVersions: () => EMPTY,
47
53
  set: noop,
@@ -50,6 +56,8 @@ var INERT = Object.freeze({
50
56
  // store + leader share getSnapshot; presence has getPeers
51
57
  getSnapshot: () => EMPTY,
52
58
  getPeers: () => NO_PEERS,
59
+ // presence
60
+ setMetadata: noop,
53
61
  // leader
54
62
  resign: noop,
55
63
  setEligible: noop,
@@ -83,11 +91,12 @@ function getStore(name, scope = "everywhere") {
83
91
  }
84
92
  return store;
85
93
  }
86
- function getPresence(name) {
87
- let presence = presences.get(name);
94
+ function getPresence(name, includeSelf = false) {
95
+ const key = includeSelf ? `self ${name}` : name;
96
+ let presence = presences.get(key);
88
97
  if (!presence) {
89
- presence = isServer() ? createServerPresence() : (0, import_core.createPresence)(name);
90
- presences.set(name, presence);
98
+ presence = isServer() ? createServerPresence() : (0, import_core.createPresence)(name, { includeSelf });
99
+ presences.set(key, presence);
91
100
  }
92
101
  return presence;
93
102
  }
@@ -96,7 +105,7 @@ function getPresence(name) {
96
105
  var import_react = require("react");
97
106
  var NO_PEERS2 = Object.freeze([]);
98
107
  function usePeers(options) {
99
- const presence = getPresence(options?.name ?? import_core.DEFAULT_NAME);
108
+ const presence = getPresence(options?.name ?? import_core.DEFAULT_NAME, options?.includeSelf ?? false);
100
109
  return (0, import_react.useSyncExternalStore)(
101
110
  (0, import_react.useCallback)((onChange) => presence.subscribe(onChange), [presence]),
102
111
  () => presence.getPeers(),
@@ -153,6 +162,13 @@ var STYLES = `
153
162
  .ue-ins__row { display: flex; gap: 8px; padding: 1px 0; }
154
163
  .ue-ins__k { color: #79c0ff; flex: none; }
155
164
  .ue-ins__v { color: #e6edf3; overflow-wrap: anywhere; }
165
+ button.ue-ins__v {
166
+ background: none;
167
+ border: 0;
168
+ padding: 0;
169
+ font: inherit;
170
+ text-align: left;
171
+ }
156
172
  .ue-ins__ver { color: #6e7681; margin-left: auto; flex: none; }
157
173
  .ue-ins__empty { color: #6e7681; }
158
174
 
@@ -163,6 +179,43 @@ var STYLES = `
163
179
  .ue-ins__dir--in { color: #3fb950; }
164
180
  .ue-ins__scope { color: #e6edf3; }
165
181
  .ue-ins__from { color: #6e7681; margin-left: auto; }
182
+
183
+ .ue-ins__tools { display: flex; gap: 6px; align-items: center; margin-bottom: 5px; }
184
+ .ue-ins__btn {
185
+ background: #21262d;
186
+ border: 1px solid #30363d;
187
+ border-radius: 5px;
188
+ color: #c9d1d9;
189
+ font: inherit;
190
+ font-size: 10px;
191
+ padding: 1px 7px;
192
+ cursor: pointer;
193
+ }
194
+ .ue-ins__btn[aria-pressed='true'] { background: #1f6feb; border-color: #1f6feb; color: #fff; }
195
+ .ue-ins__filter {
196
+ flex: 1;
197
+ min-width: 0;
198
+ background: #0d1117;
199
+ border: 1px solid #30363d;
200
+ border-radius: 5px;
201
+ color: #c9d1d9;
202
+ font: inherit;
203
+ font-size: 10px;
204
+ padding: 1px 6px;
205
+ }
206
+ .ue-ins__edit {
207
+ flex: 1;
208
+ min-width: 0;
209
+ background: #0d1117;
210
+ border: 1px solid #1f6feb;
211
+ border-radius: 4px;
212
+ color: #e6edf3;
213
+ font: inherit;
214
+ padding: 0 4px;
215
+ }
216
+ .ue-ins__v--editable { cursor: text; text-decoration: underline dotted #30363d; }
217
+ .ue-ins__v--invalid { color: #f85149; }
218
+ .ue-ins__paused { color: #d29922; }
166
219
  `;
167
220
 
168
221
  // src/devtools/inspector.tsx
@@ -181,8 +234,15 @@ function Inspector({
181
234
  const [open, setOpen] = (0, import_react2.useState)(defaultOpen);
182
235
  const [wires, setWires] = (0, import_react2.useState)([]);
183
236
  const [crown, setCrown] = (0, import_react2.useState)(null);
237
+ const [paused, setPaused] = (0, import_react2.useState)(false);
238
+ const [filter, setFilter] = (0, import_react2.useState)("");
239
+ const [editing, setEditing] = (0, import_react2.useState)(null);
184
240
  const nextId = (0, import_react2.useRef)(0);
185
241
  const crownAt = (0, import_react2.useRef)(0);
242
+ const pausedRef = (0, import_react2.useRef)(false);
243
+ (0, import_react2.useEffect)(() => {
244
+ pausedRef.current = paused;
245
+ }, [paused]);
186
246
  const peers = usePeers({ name });
187
247
  const store = getSharedStore(name);
188
248
  const subscribe = (0, import_react2.useCallback)((onChange) => store.subscribe(onChange), [store]);
@@ -208,12 +268,14 @@ function Inspector({
208
268
  crownAt.current = Date.now();
209
269
  }
210
270
  }
271
+ if (pausedRef.current) return;
211
272
  setWires(
212
273
  (prev) => [
213
274
  ...prev.slice(-(limit - 1)),
214
275
  {
215
276
  id: nextId.current++,
216
277
  direction,
278
+ scope: wire.scope,
217
279
  label: wireLabel(wire),
218
280
  from: short(wire.clientId)
219
281
  }
@@ -238,6 +300,28 @@ function Inspector({
238
300
  }, [leaseMs]);
239
301
  const selfId = store.clientId;
240
302
  const entries = Object.entries(versions);
303
+ const needle = filter.trim().toLowerCase();
304
+ const shown = needle ? wires.filter(
305
+ (wire) => wire.label.toLowerCase().includes(needle) || wire.from.toLowerCase().includes(needle)
306
+ ) : wires;
307
+ const commit = (key, draft) => {
308
+ let value;
309
+ try {
310
+ value = JSON.parse(draft);
311
+ } catch {
312
+ return;
313
+ }
314
+ store.set(key, value);
315
+ setEditing(null);
316
+ };
317
+ const draftIsValid = (draft) => {
318
+ try {
319
+ JSON.parse(draft);
320
+ return true;
321
+ } catch {
322
+ return false;
323
+ }
324
+ };
241
325
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: `ue-ins ue-ins--${position}`, "data-testid": "ue-inspector", children: [
242
326
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("style", { children: STYLES }),
243
327
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
@@ -285,7 +369,31 @@ function Inspector({
285
369
  ] }),
286
370
  entries.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "ue-ins__empty", children: "no keys yet" }) : entries.map(([key, version]) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "ue-ins__row", children: [
287
371
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "ue-ins__k", children: key }),
288
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "ue-ins__v", children: JSON.stringify(snapshot[key]) }),
372
+ editing?.key === key ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
373
+ "input",
374
+ {
375
+ className: `ue-ins__edit${draftIsValid(editing.draft) ? "" : " ue-ins__v--invalid"}`,
376
+ value: editing.draft,
377
+ autoFocus: true,
378
+ "aria-label": `Value of ${key}`,
379
+ onChange: (event) => setEditing({ key, draft: event.target.value }),
380
+ onKeyDown: (event) => {
381
+ if (event.key === "Enter") commit(key, editing.draft);
382
+ if (event.key === "Escape") setEditing(null);
383
+ },
384
+ onBlur: () => setEditing(null),
385
+ "data-testid": `ue-edit-${key}`
386
+ }
387
+ ) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
388
+ "button",
389
+ {
390
+ type: "button",
391
+ className: "ue-ins__v ue-ins__v--editable",
392
+ onClick: () => setEditing({ key, draft: JSON.stringify(snapshot[key]) ?? "" }),
393
+ "data-testid": `ue-value-${key}`,
394
+ children: JSON.stringify(snapshot[key])
395
+ }
396
+ ),
289
397
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "ue-ins__ver", children: [
290
398
  version[0],
291
399
  "\xB7",
@@ -296,10 +404,46 @@ function Inspector({
296
404
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "ue-ins__section", children: [
297
405
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "ue-ins__h", children: [
298
406
  "Wires (",
299
- wires.length,
300
- ")"
407
+ shown.length,
408
+ shown.length === wires.length ? "" : ` of ${wires.length}`,
409
+ ")",
410
+ paused ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "ue-ins__paused", children: " \xB7 paused" }) : null
411
+ ] }),
412
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "ue-ins__tools", children: [
413
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
414
+ "button",
415
+ {
416
+ type: "button",
417
+ className: "ue-ins__btn",
418
+ "aria-pressed": paused,
419
+ onClick: () => setPaused((value) => !value),
420
+ "data-testid": "ue-pause",
421
+ children: paused ? "resume" : "pause"
422
+ }
423
+ ),
424
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
425
+ "button",
426
+ {
427
+ type: "button",
428
+ className: "ue-ins__btn",
429
+ onClick: () => setWires([]),
430
+ "data-testid": "ue-clear",
431
+ children: "clear"
432
+ }
433
+ ),
434
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
435
+ "input",
436
+ {
437
+ className: "ue-ins__filter",
438
+ value: filter,
439
+ placeholder: "filter",
440
+ "aria-label": "Filter wires",
441
+ onChange: (event) => setFilter(event.target.value),
442
+ "data-testid": "ue-filter"
443
+ }
444
+ )
301
445
  ] }),
302
- wires.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "ue-ins__empty", children: "nothing yet" }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "ue-ins__log", children: wires.map((wire) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "ue-ins__wire", children: [
446
+ shown.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "ue-ins__empty", children: wires.length === 0 ? "nothing yet" : "no matches" }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "ue-ins__log", children: shown.map((wire) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "ue-ins__wire", children: [
303
447
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: `ue-ins__dir ue-ins__dir--${wire.direction}`, children: wire.direction === "out" ? "\u2192" : "\u2190" }),
304
448
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "ue-ins__scope", children: wire.label }),
305
449
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "ue-ins__from", children: wire.from })
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  getSharedStore,
4
4
  usePeers
5
- } from "../chunk-LNJEF4DI.js";
5
+ } from "../chunk-R7PBPLF7.js";
6
6
 
7
7
  // src/devtools/inspector.tsx
8
8
  import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
@@ -57,6 +57,13 @@ var STYLES = `
57
57
  .ue-ins__row { display: flex; gap: 8px; padding: 1px 0; }
58
58
  .ue-ins__k { color: #79c0ff; flex: none; }
59
59
  .ue-ins__v { color: #e6edf3; overflow-wrap: anywhere; }
60
+ button.ue-ins__v {
61
+ background: none;
62
+ border: 0;
63
+ padding: 0;
64
+ font: inherit;
65
+ text-align: left;
66
+ }
60
67
  .ue-ins__ver { color: #6e7681; margin-left: auto; flex: none; }
61
68
  .ue-ins__empty { color: #6e7681; }
62
69
 
@@ -67,6 +74,43 @@ var STYLES = `
67
74
  .ue-ins__dir--in { color: #3fb950; }
68
75
  .ue-ins__scope { color: #e6edf3; }
69
76
  .ue-ins__from { color: #6e7681; margin-left: auto; }
77
+
78
+ .ue-ins__tools { display: flex; gap: 6px; align-items: center; margin-bottom: 5px; }
79
+ .ue-ins__btn {
80
+ background: #21262d;
81
+ border: 1px solid #30363d;
82
+ border-radius: 5px;
83
+ color: #c9d1d9;
84
+ font: inherit;
85
+ font-size: 10px;
86
+ padding: 1px 7px;
87
+ cursor: pointer;
88
+ }
89
+ .ue-ins__btn[aria-pressed='true'] { background: #1f6feb; border-color: #1f6feb; color: #fff; }
90
+ .ue-ins__filter {
91
+ flex: 1;
92
+ min-width: 0;
93
+ background: #0d1117;
94
+ border: 1px solid #30363d;
95
+ border-radius: 5px;
96
+ color: #c9d1d9;
97
+ font: inherit;
98
+ font-size: 10px;
99
+ padding: 1px 6px;
100
+ }
101
+ .ue-ins__edit {
102
+ flex: 1;
103
+ min-width: 0;
104
+ background: #0d1117;
105
+ border: 1px solid #1f6feb;
106
+ border-radius: 4px;
107
+ color: #e6edf3;
108
+ font: inherit;
109
+ padding: 0 4px;
110
+ }
111
+ .ue-ins__v--editable { cursor: text; text-decoration: underline dotted #30363d; }
112
+ .ue-ins__v--invalid { color: #f85149; }
113
+ .ue-ins__paused { color: #d29922; }
70
114
  `;
71
115
 
72
116
  // src/devtools/inspector.tsx
@@ -85,8 +129,15 @@ function Inspector({
85
129
  const [open, setOpen] = useState(defaultOpen);
86
130
  const [wires, setWires] = useState([]);
87
131
  const [crown, setCrown] = useState(null);
132
+ const [paused, setPaused] = useState(false);
133
+ const [filter, setFilter] = useState("");
134
+ const [editing, setEditing] = useState(null);
88
135
  const nextId = useRef(0);
89
136
  const crownAt = useRef(0);
137
+ const pausedRef = useRef(false);
138
+ useEffect(() => {
139
+ pausedRef.current = paused;
140
+ }, [paused]);
90
141
  const peers = usePeers({ name });
91
142
  const store = getSharedStore(name);
92
143
  const subscribe = useCallback((onChange) => store.subscribe(onChange), [store]);
@@ -112,12 +163,14 @@ function Inspector({
112
163
  crownAt.current = Date.now();
113
164
  }
114
165
  }
166
+ if (pausedRef.current) return;
115
167
  setWires(
116
168
  (prev) => [
117
169
  ...prev.slice(-(limit - 1)),
118
170
  {
119
171
  id: nextId.current++,
120
172
  direction,
173
+ scope: wire.scope,
121
174
  label: wireLabel(wire),
122
175
  from: short(wire.clientId)
123
176
  }
@@ -142,6 +195,28 @@ function Inspector({
142
195
  }, [leaseMs]);
143
196
  const selfId = store.clientId;
144
197
  const entries = Object.entries(versions);
198
+ const needle = filter.trim().toLowerCase();
199
+ const shown = needle ? wires.filter(
200
+ (wire) => wire.label.toLowerCase().includes(needle) || wire.from.toLowerCase().includes(needle)
201
+ ) : wires;
202
+ const commit = (key, draft) => {
203
+ let value;
204
+ try {
205
+ value = JSON.parse(draft);
206
+ } catch {
207
+ return;
208
+ }
209
+ store.set(key, value);
210
+ setEditing(null);
211
+ };
212
+ const draftIsValid = (draft) => {
213
+ try {
214
+ JSON.parse(draft);
215
+ return true;
216
+ } catch {
217
+ return false;
218
+ }
219
+ };
145
220
  return /* @__PURE__ */ jsxs("div", { className: `ue-ins ue-ins--${position}`, "data-testid": "ue-inspector", children: [
146
221
  /* @__PURE__ */ jsx("style", { children: STYLES }),
147
222
  /* @__PURE__ */ jsxs(
@@ -189,7 +264,31 @@ function Inspector({
189
264
  ] }),
190
265
  entries.length === 0 ? /* @__PURE__ */ jsx("div", { className: "ue-ins__empty", children: "no keys yet" }) : entries.map(([key, version]) => /* @__PURE__ */ jsxs("div", { className: "ue-ins__row", children: [
191
266
  /* @__PURE__ */ jsx("span", { className: "ue-ins__k", children: key }),
192
- /* @__PURE__ */ jsx("span", { className: "ue-ins__v", children: JSON.stringify(snapshot[key]) }),
267
+ editing?.key === key ? /* @__PURE__ */ jsx(
268
+ "input",
269
+ {
270
+ className: `ue-ins__edit${draftIsValid(editing.draft) ? "" : " ue-ins__v--invalid"}`,
271
+ value: editing.draft,
272
+ autoFocus: true,
273
+ "aria-label": `Value of ${key}`,
274
+ onChange: (event) => setEditing({ key, draft: event.target.value }),
275
+ onKeyDown: (event) => {
276
+ if (event.key === "Enter") commit(key, editing.draft);
277
+ if (event.key === "Escape") setEditing(null);
278
+ },
279
+ onBlur: () => setEditing(null),
280
+ "data-testid": `ue-edit-${key}`
281
+ }
282
+ ) : /* @__PURE__ */ jsx(
283
+ "button",
284
+ {
285
+ type: "button",
286
+ className: "ue-ins__v ue-ins__v--editable",
287
+ onClick: () => setEditing({ key, draft: JSON.stringify(snapshot[key]) ?? "" }),
288
+ "data-testid": `ue-value-${key}`,
289
+ children: JSON.stringify(snapshot[key])
290
+ }
291
+ ),
193
292
  /* @__PURE__ */ jsxs("span", { className: "ue-ins__ver", children: [
194
293
  version[0],
195
294
  "\xB7",
@@ -200,10 +299,46 @@ function Inspector({
200
299
  /* @__PURE__ */ jsxs("div", { className: "ue-ins__section", children: [
201
300
  /* @__PURE__ */ jsxs("div", { className: "ue-ins__h", children: [
202
301
  "Wires (",
203
- wires.length,
204
- ")"
302
+ shown.length,
303
+ shown.length === wires.length ? "" : ` of ${wires.length}`,
304
+ ")",
305
+ paused ? /* @__PURE__ */ jsx("span", { className: "ue-ins__paused", children: " \xB7 paused" }) : null
306
+ ] }),
307
+ /* @__PURE__ */ jsxs("div", { className: "ue-ins__tools", children: [
308
+ /* @__PURE__ */ jsx(
309
+ "button",
310
+ {
311
+ type: "button",
312
+ className: "ue-ins__btn",
313
+ "aria-pressed": paused,
314
+ onClick: () => setPaused((value) => !value),
315
+ "data-testid": "ue-pause",
316
+ children: paused ? "resume" : "pause"
317
+ }
318
+ ),
319
+ /* @__PURE__ */ jsx(
320
+ "button",
321
+ {
322
+ type: "button",
323
+ className: "ue-ins__btn",
324
+ onClick: () => setWires([]),
325
+ "data-testid": "ue-clear",
326
+ children: "clear"
327
+ }
328
+ ),
329
+ /* @__PURE__ */ jsx(
330
+ "input",
331
+ {
332
+ className: "ue-ins__filter",
333
+ value: filter,
334
+ placeholder: "filter",
335
+ "aria-label": "Filter wires",
336
+ onChange: (event) => setFilter(event.target.value),
337
+ "data-testid": "ue-filter"
338
+ }
339
+ )
205
340
  ] }),
206
- wires.length === 0 ? /* @__PURE__ */ jsx("div", { className: "ue-ins__empty", children: "nothing yet" }) : /* @__PURE__ */ jsx("div", { className: "ue-ins__log", children: wires.map((wire) => /* @__PURE__ */ jsxs("div", { className: "ue-ins__wire", children: [
341
+ shown.length === 0 ? /* @__PURE__ */ jsx("div", { className: "ue-ins__empty", children: wires.length === 0 ? "nothing yet" : "no matches" }) : /* @__PURE__ */ jsx("div", { className: "ue-ins__log", children: shown.map((wire) => /* @__PURE__ */ jsxs("div", { className: "ue-ins__wire", children: [
207
342
  /* @__PURE__ */ jsx("span", { className: `ue-ins__dir ue-ins__dir--${wire.direction}`, children: wire.direction === "out" ? "\u2192" : "\u2190" }),
208
343
  /* @__PURE__ */ jsx("span", { className: "ue-ins__scope", children: wire.label }),
209
344
  /* @__PURE__ */ jsx("span", { className: "ue-ins__from", children: wire.from })