sygnal 6.6.0 → 7.0.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.
Files changed (82) hide show
  1. package/CHANGELOG.md +132 -0
  2. package/README.md +2 -0
  3. package/dist/astro/index.cjs.js +32 -9
  4. package/dist/astro/index.cjs.js.map +1 -1
  5. package/dist/astro/index.mjs +32 -9
  6. package/dist/astro/index.mjs.map +1 -1
  7. package/dist/astro/server.cjs.js.map +1 -1
  8. package/dist/astro/server.mjs.map +1 -1
  9. package/dist/devtools.cjs.js.map +1 -1
  10. package/dist/devtools.esm.js.map +1 -1
  11. package/dist/diagnostics.cjs.js +87 -4
  12. package/dist/diagnostics.cjs.js.map +1 -1
  13. package/dist/diagnostics.esm.js +86 -5
  14. package/dist/diagnostics.esm.js.map +1 -1
  15. package/dist/guide/accessibility.md +2 -0
  16. package/dist/guide/ai-chat.md +6 -6
  17. package/dist/guide/behaviors.md +1 -1
  18. package/dist/guide/element-commands.md +4 -2
  19. package/dist/guide/forms-reference.md +89 -3
  20. package/dist/guide/forms.md +8 -8
  21. package/dist/guide/http.md +77 -7
  22. package/dist/guide/migrating-to-7.md +314 -0
  23. package/dist/guide/recipes/ai-summarize.md +2 -1
  24. package/dist/guide/recipes/i18n.md +1 -1
  25. package/dist/guide/ui/tabs.md +5 -4
  26. package/dist/guide/ui/tooltip.md +24 -0
  27. package/dist/index.cjs.js +772 -318
  28. package/dist/index.cjs.js.map +1 -1
  29. package/dist/index.d.ts +198 -31
  30. package/dist/index.esm.js +772 -318
  31. package/dist/index.esm.js.map +1 -1
  32. package/dist/sygnal.min.js +1 -1
  33. package/dist/sygnal.min.js.map +1 -1
  34. package/dist/ui.cjs.js +96 -11
  35. package/dist/ui.cjs.js.map +1 -1
  36. package/dist/ui.esm.js +96 -12
  37. package/dist/ui.esm.js.map +1 -1
  38. package/dist/vike/config/package.json +1 -1
  39. package/dist/vite/plugin.cjs.js +19 -9
  40. package/dist/vite/plugin.cjs.js.map +1 -1
  41. package/dist/vite/plugin.mjs +19 -9
  42. package/dist/vite/plugin.mjs.map +1 -1
  43. package/llms.txt +17 -16
  44. package/package.json +1 -1
  45. package/src/astro/index.d.ts +8 -0
  46. package/src/astro/index.ts +18 -0
  47. package/src/core/actions.ts +28 -21
  48. package/src/core/define.ts +2 -0
  49. package/src/core/hooks.ts +2 -2
  50. package/src/core/hosts/collection.ts +4 -3
  51. package/src/core/instance.ts +60 -16
  52. package/src/core/runtime.ts +5 -1
  53. package/src/extra/ai/chat/behavior.ts +3 -3
  54. package/src/extra/ai/commandBar.ts +3 -3
  55. package/src/extra/ai/compose/behavior.ts +3 -3
  56. package/src/extra/ai/transports/shared.ts +8 -2
  57. package/src/extra/behaviors.ts +21 -0
  58. package/src/extra/diagnostics/checks/fetch.ts +21 -1
  59. package/src/extra/diagnostics/checks/forms.ts +4 -3
  60. package/src/extra/diagnostics/checks/index.ts +1 -0
  61. package/src/extra/diagnostics/checks/next.ts +3 -0
  62. package/src/extra/diagnostics/checks/public.d.ts +12 -0
  63. package/src/extra/diagnostics/checks/timingAudit.ts +47 -0
  64. package/src/extra/diagnostics/codes.ts +13 -0
  65. package/src/extra/fetchDriver.ts +112 -7
  66. package/src/extra/fetchEventSource.ts +113 -0
  67. package/src/extra/form.ts +68 -26
  68. package/src/extra/formHelpers.ts +3 -1
  69. package/src/extra/lens.ts +3 -2
  70. package/src/extra/queryCache.ts +4 -3
  71. package/src/extra/socketDriver.ts +10 -4
  72. package/src/extra/testing.ts +68 -11
  73. package/src/index.d.ts +197 -30
  74. package/src/index.ts +1 -0
  75. package/src/shared.ts +3 -0
  76. package/src/ui/dialog.ts +3 -2
  77. package/src/ui/tooltips.ts +92 -0
  78. package/src/ui.d.ts +35 -0
  79. package/src/ui.ts +1 -0
  80. package/src/vite/graph.ts +11 -3
  81. package/src/vite/plugin.d.ts +7 -0
  82. package/src/vite/plugin.ts +10 -5
@@ -252,6 +252,13 @@ export interface RenderOptions {
252
252
  * Default: unchanged (off unless configureStrict(true) was called).
253
253
  */
254
254
  strict?: boolean;
255
+ /**
256
+ * 7.0 timing audit (SYG257, info) while the component is rendered; restored on dispose():
257
+ * each output sink that reads a top-level state field its action's STATE changed is reported
258
+ * (it gets the new value since 7.0; 6.x gave it the old one). Requires 'sygnal/diagnostics'.
259
+ * Default: unchanged (off unless configureTimingAudit(true) was called).
260
+ */
261
+ timingAudit?: boolean;
255
262
  /**
256
263
  * settle()'s quiet window in ms (default 20): settle() resolves once nothing in the tree has
257
264
  * rendered, reduced or changed state for this long. A model `next('X', data, ms)` with a
@@ -454,6 +461,11 @@ export interface RenderResult {
454
461
  * `status`/`body`), or an Error / message (a network failure: the fetch rejects with it).
455
462
  */
456
463
  fail: (sinkName: string, error: any, target?: FakeReplyTarget) => Promise<void>;
464
+ /**
465
+ * PLAN-11 U-2: one progress step `{ loaded, total?, phase? }` of a pending request sent with
466
+ * `progress` (chosen as in respond()); it stays pending until respond()/fail().
467
+ */
468
+ progress: (sinkName: string, step: {loaded: number; total?: number; phase?: 'upload' | 'download'}, target?: FakeReplyTarget) => Promise<void>;
457
469
  /**
458
470
  * PLAN-6 L-4: stream chunks into a pending request of the LLM fake (the sink named by `llmSink`,
459
471
  * default 'LLM': the real chat driver over an in-memory transport). Chunks: strings (text),
@@ -550,9 +562,9 @@ export interface RenderResult {
550
562
  * render (await t.ready() first). 4-A1: after `await` of any wait, it shows the state the wait
551
563
  * resolved with (a later render is held back until the next macrotask).
552
564
  */
553
- query: (selector: string) => Element | null;
565
+ query: <E extends Element = Element>(selector: string) => E | null;
554
566
  /** Every element matching a selector in the rendered tree (Portals included), as query() */
555
- queryAll: (selector: string) => Element[];
567
+ queryAll: <E extends Element = Element>(selector: string) => E[];
556
568
  /**
557
569
  * PLAN-5 W-1: a widget's host by selector or control: `.props` (what the view passed it),
558
570
  * `.instance` (`dom: 'real'`: what mount returned) and `.dispatch(name, detail)` (the event
@@ -1197,6 +1209,13 @@ const validMs = (v: any) => typeof v == 'number' && Number.isFinite(v) && v >= 0
1197
1209
  // too, so its time is the clock's (Date may be left real by `toFake`), and its waits drive it.
1198
1210
  const fakeClock = (): any => (setTimeout as any).clock;
1199
1211
  const clockNow = (): number => { const c = fakeClock(); return c ? c.now : Date.now(); };
1212
+ /** PLAN-11 Q-3: ms until the next pending fake timer (0: none, or real timers) */
1213
+ const nextFakeTimer = (): number => {
1214
+ const c = fakeClock();
1215
+ let due = Infinity;
1216
+ try { Object.values(c?.timers || {}).forEach((t: any) => { if (t && t.callAt > c.now && t.callAt < due) due = t.callAt; }); } catch (_) {}
1217
+ return due < Infinity ? due - c.now : 0;
1218
+ };
1200
1219
  /**
1201
1220
  * Under fake timers, advance the clock timer by timer (nextAsync flushes promises around each
1202
1221
  * one) until `p` settles. Every pending wait has a timer (its timeout, a quiet-window tick), so
@@ -1369,6 +1388,7 @@ const headFake = () => {
1369
1388
  };
1370
1389
  let savedConfig: ReturnType<typeof _getDiagnosticsConfig>;
1371
1390
  let savedStrict: any;
1391
+ let savedAudit: any;
1372
1392
 
1373
1393
  /*
1374
1394
  * PLAN-4 GS-2, mock DOM: could the element a command targets have this method? The core runs any
@@ -1468,7 +1488,7 @@ export function renderComponent(
1468
1488
  componentDef: any,
1469
1489
  options: RenderOptions = {}
1470
1490
  ): RenderResult {
1471
- const {initialState, mockConfig = {}, drivers = {}, diagnostics, strict, dom = 'mock', autoConnect = true, socketSink = 'WS', resourceSink = 'HTTP', llmSink = 'LLM', http: httpOptions, context: ancestors} = options;
1491
+ const {initialState, mockConfig = {}, drivers = {}, diagnostics, strict, timingAudit, dom = 'mock', autoConnect = true, socketSink = 'WS', resourceSink = 'HTTP', llmSink = 'LLM', http: httpOptions, context: ancestors} = options;
1472
1492
  const {intent, model = {}} = componentDef;
1473
1493
  // E4: real DOM mode
1474
1494
  const real = dom == 'real';
@@ -1511,6 +1531,7 @@ export function renderComponent(
1511
1531
  if (!active++) {
1512
1532
  savedConfig = _getDiagnosticsConfig();
1513
1533
  savedStrict = core.strict;
1534
+ savedAudit = core.timingAudit;
1514
1535
  // G-051: forget the checks' report dedupe (once()), so a finding from an earlier test is
1515
1536
  // reported again. Only that set: resetChecks() would also drop inspect()'s records of
1516
1537
  // other live instances / apps.
@@ -1518,6 +1539,8 @@ export function renderComponent(
1518
1539
  }
1519
1540
  configureDiagnostics({mode: diagnostics || (prevMode == 'off' ? 'collect' : prevMode)});
1520
1541
  if (strict !== undefined) core.strict = strict;
1542
+ // 7.0 timing audit (SYG257, 'sygnal/diagnostics'): same bridge flag as configureTimingAudit()
1543
+ if (timingAudit !== undefined) core.timingAudit = timingAudit;
1521
1544
  // PLAN-4 GS-2: element commands run on the real DOM (jsdom gets the missing methods), and their
1522
1545
  // SYG640/SYG641 are reported without the dev entry too
1523
1546
  if (real && !domFakes) domFakes = fakeElementMethods(document.defaultView || globalThis);
@@ -1683,6 +1706,7 @@ export function renderComponent(
1683
1706
  if (!--active) {
1684
1707
  configureDiagnostics(savedConfig);
1685
1708
  core.strict = savedStrict;
1709
+ core.timingAudit = savedAudit;
1686
1710
  domFakes?.();
1687
1711
  domFakes = undefined;
1688
1712
  }
@@ -1716,7 +1740,8 @@ export function renderComponent(
1716
1740
  // value: what t.requests lists (normalised); req: the request the driver got; res: the resource;
1717
1741
  // pf: a { prefetch } fetch (5-5: answered into the cache, no reply)
1718
1742
  // llm: the in-memory stream handle of an LLM fake request (PLAN-6 L-4)
1719
- type Pending = {value: any; req: any; category: any; res?: string; pf?: any; live: boolean; settle: (ok: boolean, v: any) => void; llm?: any};
1743
+ // step: PLAN-11 U-2, the driver's progress callback of a request sent with `progress` (t.progress)
1744
+ type Pending = {value: any; req: any; category: any; res?: string; pf?: any; live: boolean; settle: (ok: boolean, v: any) => void; llm?: any; step?: (phase: string, loaded: number, total: number) => void};
1720
1745
  type Fake = {select: any; errors: any; subs: Set<FakeSub>; at: (ns: any[]) => any; pending: Pending[]; in$: any; http: any; ws: Sock; llm?: any};
1721
1746
  const fakes = new Map<string, Fake>();
1722
1747
  // 5-3: the fake drivers' focus / online listeners (t.focus, t.online)
@@ -1749,14 +1774,22 @@ export function renderComponent(
1749
1774
  const lin$ = xs.create();
1750
1775
  llm = {in$: lin$, src: makeChatDriver({transport: tr, _frame: (f: () => void) => { frames.push(f); }})(lin$)};
1751
1776
  }
1777
+ let memFetch: any;
1752
1778
  const http = makeFetchDriver({
1753
1779
  ...httpOptions,
1780
+ // PLAN-11 U-2: a progress request is answered like any other (t.respond / t.fail); its
1781
+ // pending entry keeps the driver's progress callback for t.progress
1782
+ _progress: (url: string, init: any, step: any) => {
1783
+ const r = memFetch(url, init);
1784
+ pending[pending.length - 1].step = step;
1785
+ return r;
1786
+ },
1754
1787
  // 5-3: focus / online come from t.focus() / t.online() only
1755
1788
  _on: (f: any) => { signals.add(f); return () => signals.delete(f); },
1756
1789
  _tap: (req: any, res?: string, pf?: any) => { tapped = [req, res, pf]; },
1757
1790
  // D472: a batch / split request (a large decide()) is one fetch here, answered as one
1758
1791
  _whole: true,
1759
- fetch: (url: string, init: any) => new Promise((resolve, reject) => {
1792
+ fetch: memFetch = (url: string, init: any) => new Promise((resolve, reject) => {
1760
1793
  const [req, res, pf] = tapped || [{url}];
1761
1794
  tapped = undefined;
1762
1795
  // G-171(1): a resource fetch is listed as { url, ...request, resource: name }; 5-5: a
@@ -1788,6 +1821,7 @@ export function renderComponent(
1788
1821
  return {
1789
1822
  select: (sel?: any) => xs.merge(hs.select(sel), own(false)(sel), sock.select(sel)),
1790
1823
  errors: (sel?: any) => xs.merge(hs.errors(sel), own(true)(sel)),
1824
+ progress: (sel?: any) => hs.progress(sel),
1791
1825
  subs, at, pending, in$, http, ws, llm,
1792
1826
  // 5-3: the driver's matcher / inspection (the dev checks' SYG632, t.cache)
1793
1827
  __matches: http.__matches, __inspect: http.__inspect,
@@ -2473,7 +2507,7 @@ export function renderComponent(
2473
2507
  if (tg === undefined) return {match: cat, desc: catDesc, o};
2474
2508
  if (typeof tg == 'string') {
2475
2509
  // an ok/error action name, key, category, resource name or URL
2476
- return {match: r => { const v = r.value || {}; return cat(r) && [v.ok, v.error, v.key, v.category, r.res, v.url].includes(tg); }, desc: ` matching '${tg}'${catDesc}`, o};
2510
+ return {match: r => { const v = r.value || {}; return cat(r) && [v.ok, v.error, v.progress, v.key, v.category, r.res, v.url].includes(tg); }, desc: ` matching '${tg}'${catDesc}`, o};
2477
2511
  }
2478
2512
  if (typeof tg == 'function') {
2479
2513
  return {match: r => { try { return cat(r) && !!tg(r.value); } catch (_) { return false; } }, desc: ` matching the predicate${catDesc}`, o};
@@ -2706,6 +2740,23 @@ export function renderComponent(
2706
2740
  return {error: err, category, request: e?.req, status: status ?? x?.status, body};
2707
2741
  });
2708
2742
 
2743
+ // PLAN-11 U-2: t.progress. One progress step of a pending request sent with `progress` (its
2744
+ // reply action, or progress() for `progress: true`); the request stays pending. Targets as
2745
+ // t.respond's; the phase defaults to 'upload' for a request with a body, else 'download'
2746
+ const progress = (name: string, step: any, opts?: FakeReplyTarget) => {
2747
+ throwFailure();
2748
+ if (drivers[name]) throw new Error(`[Sygnal] t.progress('${name}'): ${name} has a real driver (passed in drivers), so there is nothing to script`);
2749
+ if (!step || typeof step != 'object' || typeof step.loaded != 'number') throw new Error(`[Sygnal] t.progress('${name}', step): step is { loaded, total?, phase? } (got ${brief(step)})`);
2750
+ const tg = targetOf(opts);
2751
+ const what = `t.progress('${name}'${typeof opts == 'string' ? `, …, '${opts}'` : ''})`;
2752
+ const now = isReady && !inputs.length ? pick(name, tg) : undefined;
2753
+ if (now && !now.step) throw new Error(`[Sygnal] ${what}: the request ${brief(now.value)} has no progress: send it with progress: 'UPLOADING' (a reply action) or progress: true`);
2754
+ return scripted(() => pick(name, tg), w => noPending(what, name, tg, w), (e: Pending) => {
2755
+ if (!e.step) return new Error(`[Sygnal] ${what}: the request ${brief(e.value)} has no progress: send it with progress: 'UPLOADING' (a reply action) or progress: true`);
2756
+ const r = e.req || {};
2757
+ e.step(step.phase || (r.body !== undefined || r.json !== undefined || r.multipart !== undefined ? 'upload' : 'download'), step.loaded, step.total ?? 0);
2758
+ }, sendDue);
2759
+ };
2709
2760
  // PLAN-6 L-4: t.stream. Targets as t.respond's (an action name or key, a partial request, a
2710
2761
  // predicate, { request, nth }); `end: false` (the last argument, or in the target options)
2711
2762
  // leaves the stream open. Chunks become ChatEvents pushed into the in-memory stream; the fake's
@@ -2904,11 +2955,11 @@ export function renderComponent(
2904
2955
  };
2905
2956
  // 6-B (G-185): on the mock DOM, MockElement snapshots of the latest rendered tree
2906
2957
  const mockAll = (s: string): any[] => findAll(vtree, parse(norm(s)), [], []).map(c => mockOf(c, htmlOf));
2907
- const queryAll = (s: string): Element[] => {
2958
+ const queryAll = <E extends Element = Element>(s: string): E[] => {
2908
2959
  s = selOf(s);
2909
2960
  notYet(`t.queryAll('${s}')`);
2910
2961
  if (!real) return mockAll(s);
2911
- return roots().flatMap(r => Array.from(r.querySelectorAll(s)));
2962
+ return roots().flatMap(r => Array.from(r.querySelectorAll<E>(s)));
2912
2963
  };
2913
2964
  const queryIn = (s: string): Element | null => {
2914
2965
  for (const r of roots()) {
@@ -2917,11 +2968,11 @@ export function renderComponent(
2917
2968
  }
2918
2969
  return null;
2919
2970
  };
2920
- const query = (s: string): Element | null => {
2971
+ const query = <E extends Element = Element>(s: string): E | null => {
2921
2972
  s = selOf(s);
2922
2973
  notYet(`t.query('${s}')`);
2923
2974
  if (!real) return mockAll(s)[0] ?? null;
2924
- return queryIn(s);
2975
+ return queryIn(s) as E | null;
2925
2976
  };
2926
2977
 
2927
2978
  // PLAN-5 W-1: a widget host (selector or control): the props it was rendered with (the mock
@@ -3134,7 +3185,12 @@ export function renderComponent(
3134
3185
  let msg = `${name} timed out after ${timeoutMs}ms.`;
3135
3186
  const [why, pending] = explainNext(start);
3136
3187
  msg += why;
3137
- if (pending) msg += ` Wait longer: t.${name}(pred, ms), or the renderComponent timeoutMs option (default for every wait).`;
3188
+ if (inputs.length) msg += ` ${inputs.length} simulated input(s) still pending.`;
3189
+ // PLAN-11 Q-3: on the fake clock a state a timer (TIMER driver, periodic, delay) brings
3190
+ // later than the timeout can't arrive in time: name the next pending timer
3191
+ const later = pending ? 0 : nextFakeTimer();
3192
+ if (later) msg += ` A timer is still pending on the fake clock (it fires ${later}ms after this timeout): if the state comes from it, this wait ended first.`;
3193
+ if (pending || later) msg += ` Wait longer: t.${name}(pred, ms), or the renderComponent timeoutMs option (default for every wait).`;
3138
3194
  // G-065: a state from before the call matched; next() only looks at new ones
3139
3195
  for (let i = from - 1; i >= 0; i--) {
3140
3196
  if (test(i)) {
@@ -3317,6 +3373,7 @@ export function renderComponent(
3317
3373
  requests,
3318
3374
  respond,
3319
3375
  fail,
3376
+ progress,
3320
3377
  stream,
3321
3378
  connections,
3322
3379
  cache,
package/src/index.d.ts CHANGED
@@ -53,9 +53,12 @@ type ComponentProps<STATE, PROPS, CONTEXT> = (
53
53
  */
54
54
  export type UidFunction = (name?: string) => string
55
55
 
56
- /** The first argument of a component's view: its props plus `state`, `context`, `children`, `slots` and `uid`. */
56
+ /**
57
+ * The first argument of a component's view: its props plus `state`, `context`, `children`, `slots` and `uid`.
58
+ * PLAN-11 Q-1: PROPS = `any` is read as "any other props" (`any & X` would make `state` `any` too).
59
+ */
57
60
  export type ViewProps<STATE = any, PROPS = {}, CONTEXT = {}> =
58
- PROPS & { state: STATE; context: CONTEXT; children?: JSX.Element | JSX.Element[]; slots?: Record<string, JSX.Element[]>; uid: UidFunction }
61
+ (0 extends (1 & PROPS) ? { [prop: string]: any } : PROPS) & { state: STATE; context: CONTEXT; children?: JSX.Element | JSX.Element[]; slots?: Record<string, JSX.Element[]>; uid: UidFunction }
59
62
 
60
63
  /**
61
64
  * The `state` prop a parent passes to a sub-component in JSX: the name of a field of the
@@ -90,13 +93,19 @@ type NextFunction<ACTIONS = any> = ACTIONS extends object
90
93
  ) => void
91
94
  : (action: string, data?: any, delay?: number) => void
92
95
 
93
- type ReducerExtras<PROPS, CONTEXT> = PROPS & { context: CONTEXT; children?: JSX.Element | JSX.Element[]; slots?: Record<string, JSX.Element[]>; uid: UidFunction }
96
+
97
+ /**
98
+ * A reducer's 4th argument. 7.0: STATE runs first; every other sink gets the committed state as
99
+ * `state` (and as its 1st argument), with the state from before the action as `previousState`
100
+ * (in a STATE reducer both are the old state)
101
+ */
102
+ type ReducerExtras<PROPS, CONTEXT, STATE = any> = PROPS & { state: STATE; previousState: STATE; context: CONTEXT; children?: JSX.Element | JSX.Element[]; slots?: Record<string, JSX.Element[]>; uid: UidFunction }
94
103
 
95
104
  type Reducer<STATE, PROPS, ACTIONS = any, DATA = any, RETURN = any, CONTEXT = {}> = (
96
105
  state: STATE,
97
106
  args: DATA,
98
107
  next: NextFunction<ACTIONS>,
99
- props: ReducerExtras<PROPS, CONTEXT>
108
+ props: ReducerExtras<PROPS, CONTEXT, STATE>
100
109
  ) => RETURN | ABORT | undefined
101
110
 
102
111
  export type ExactShape<EXPECTED, ACTUAL extends EXPECTED> = ACTUAL &
@@ -232,7 +241,7 @@ type AnySinkConstant =
232
241
  * `props.signal` aborts on DISPOSE (undefined where AbortController is missing).
233
242
  */
234
243
  type EffectReducer<STATE, PROPS, ACTIONS, DATA, CALCULATED, CONTEXT = {}> =
235
- | ((state: STATE & CALCULATED, args: DATA, next: NextFunction<ACTIONS>, props: ReducerExtras<PROPS, CONTEXT> & { signal?: AbortSignal }) => void)
244
+ | ((state: STATE & CALCULATED, args: DATA, next: NextFunction<ACTIONS>, props: ReducerExtras<PROPS, CONTEXT, STATE & CALCULATED> & { signal?: AbortSignal }) => void)
236
245
 
237
246
  type DefaultSinks<STATE, PROPS, ACTIONS, DATA, CALCULATED, SINK_RETURNS extends NonStateSinkReturns = {}, CONTEXT = {}> = {
238
247
  STATE?: SinkValue<STATE, PROPS, ACTIONS, DATA, STATE, CALCULATED, CONTEXT>;
@@ -902,10 +911,11 @@ export function pager(options?: PagerOptions): Behavior<PagerState, PagerActions
902
911
  export type FieldErrors = Record<string, string>
903
912
 
904
913
  /** One field of a `form` slice: `state.form.fields.email`, `state.form.fields['addresses.7.city']`. */
905
- export interface FormField {
914
+ export interface FormField<T = any> {
906
915
  /** The field name, for `name={f.name}` (the path in `values`; rows of an array by id) */
907
916
  name: string;
908
- value: any;
917
+ /** The field's value: typed for a top-level field of a typed form (`fields.email.value: string`) */
918
+ value: T;
909
919
  /** What to show: a server or check error at once, a schema error once the field is touched (per `show`) or after a submit; '' when none */
910
920
  error: string;
911
921
  /** `!!error`, for `aria-invalid={f.email.invalid}` */
@@ -942,12 +952,36 @@ export interface FormState<V = any> {
942
952
  validating: boolean;
943
953
  /** The schema has answered at least once since the start (or the last `form.RESET`) */
944
954
  validated: boolean;
955
+ /**
956
+ * PLAN-11 (6.7): the submit request's last progress step, when it is sent with
957
+ * `progress: 'form.PROGRESS'`; null otherwise and after `form.DONE` / `form.ERRORS` / `form.RESET`
958
+ */
959
+ progress: { phase: 'upload' | 'download'; loaded: number; total: number | null; fraction: number | null } | null;
960
+ /**
961
+ * PLAN-11 (6.7): how many times the form started over (`form.RESET`, `resetOnShow`). A file
962
+ * input can't be cleared through state: key it with this, `key={'avatar' + state.form.resets}`
963
+ */
964
+ resets: number;
945
965
  }
946
966
 
967
+ type FormIsAny<T> = 0 extends (1 & T) ? true : false
968
+
969
+ /**
970
+ * A form's `fields` (PLAN-11 F-1): the top-level fields of `values` by name, typed
971
+ * (`fields.email.value: string`), and nested paths ('addresses.7.city', 'addresses.7') with any
972
+ * value. A top-level name that isn't in `values` is a type error (a typo). Untyped values (any):
973
+ * `Record<string, FormField>`.
974
+ */
975
+ export type FormFields<V = any> = FormIsAny<V> extends true
976
+ ? Record<string, FormField>
977
+ : V extends object
978
+ ? { [K in keyof V & string]-?: FormField<V[K]> } & { [path: `${string}.${string}`]: FormField }
979
+ : Record<string, FormField>
980
+
947
981
  /** A `form` slice's calculated fields. */
948
- export interface FormCalculated {
982
+ export interface FormCalculated<V = any> {
949
983
  /** Every field of `values` by name (leaves, arrays, and array rows' fields by row id) */
950
- fields: Record<string, FormField>;
984
+ fields: FormFields<V>;
951
985
  /** No schema error and no failed check, as of the schema's last answer (false until its first; an async re-validation keeps the previous value) */
952
986
  valid: boolean;
953
987
  dirty: boolean;
@@ -963,15 +997,38 @@ export interface FormFieldCheck {
963
997
  error?: (body: any) => string | false | null | undefined;
964
998
  }
965
999
 
966
- export interface FormOptions<V = any> {
1000
+ /** Types only: the key of `FormOptions`' phantom output type (not exported, never set) */
1001
+ declare const formOutput: unique symbol
1002
+
1003
+ /** PLAN-11: the `request` option of form(): a request without reply actions (the form names them), or a function of the submit data and the host state */
1004
+ type FormRequestObject = Exclude<FetchRequest, string> & { url: string; ok?: never; error?: never }
1005
+ export type FormRequest<OUT = any> =
1006
+ | FormRequestObject
1007
+ | ((data: OUT, state: any) => FormRequestObject | typeof ABORT | null | undefined | false)
1008
+
1009
+ export interface FormOptions<V = any, OUT = any> {
1010
+ /** Phantom, types only: the schema's output (`FormOutput<typeof uses.form>`); never set */
1011
+ readonly [formOutput]?: OUT;
967
1012
  /**
968
1013
  * The start values; field names are paths in it (rows of an array of objects need an `id`, SYG236).
969
1014
  * Or a function of the host component's state, for start values that come from state (a default
970
1015
  * from the settings): called when the form starts, and each time it starts over (`resetOnShow`)
971
1016
  */
972
1017
  values: V | ((state: any) => V);
973
- /** The host action a valid submit dispatches with the schema's output (SYG234 when it has no model entry). With an `http` sink in its entry the submit is pending until `form.DONE` / `form.ERRORS`; otherwise it is done at once */
974
- submit: string;
1018
+ /**
1019
+ * The host action a valid submit dispatches with the schema's output (SYG234 when it has no model entry). With an `http` sink in its entry the submit is pending until `form.DONE` / `form.ERRORS`; otherwise it is done at once.
1020
+ * Optional with `request` (then dispatched too, for state; its entry should send no request)
1021
+ */
1022
+ submit?: string;
1023
+ /**
1024
+ * PLAN-11 (6.7): the form sends a valid submit itself on the `http` sink: a request, or a
1025
+ * function of the schema's output and the host's state (`(data, state) => ({ url: '/api/items/' + state.id, method: 'PUT' })`;
1026
+ * `ABORT` / falsy sends nothing and leaves the submit pending). The form adds the body
1027
+ * (`multipart: data` when a top-level value is a File or files, else `json: data`, unless the
1028
+ * request sets `json`, `multipart` or `body`), `progress: 'form.PROGRESS'` (unless set) and its own
1029
+ * `ok: 'form.DONE'`, `error: 'form.ERRORS'`
1030
+ */
1031
+ request?: FormRequest<OUT>;
975
1032
  /** Async checks by field name: run on blur and before a submit, which waits for them */
976
1033
  check?: Record<string, FormFieldCheck>;
977
1034
  /** When a schema error shows: after the field's blur (default), on input, or only after a submit */
@@ -1001,9 +1058,19 @@ export interface FormOptions<V = any> {
1001
1058
  tool?: import('./ai.d').FormToolHandle;
1002
1059
  }
1003
1060
 
1004
- export interface FormActions {
1005
- /** A field changed (the form element's input events): `{ name, value }`; a checkbox gives `checked` as `value` and its own value as `item` (on an array field: added or removed) */
1006
- CHANGE: { name: string; value: any; item?: any };
1061
+ /**
1062
+ * What `form.ERRORS` takes (the runtime's `replyErrors`): an error reply (a driver's
1063
+ * `{ error, status?, body?, request }` or `{ status, body }`, the body `{ errors }` or the map
1064
+ * itself), a map `{ name: message | messages }`, or a list of issues `[{ path, message }]`
1065
+ */
1066
+ export type FormServerErrors =
1067
+ | Record<string, string | ReadonlyArray<string>>
1068
+ | ReadonlyArray<{ message: string; path?: ReadonlyArray<any> }>
1069
+ | { status?: number; body?: any; error?: any; request?: any }
1070
+
1071
+ export interface FormActions<V = any> {
1072
+ /** A field changed (the form element's input events): `{ name, value }`; a checkbox gives `checked` as `value` and its own value as `item` (on an array field: added or removed); a file input its File (`multiple`: File[]; none: null) */
1073
+ CHANGE: { name: string; value: any; item?: any; file?: boolean };
1007
1074
  /**
1008
1075
  * Several fields at once, by field name (`{ values: { name: 'Dana', 'addresses.7.city': 'Hilo' } }`),
1009
1076
  * as if typed: one validation, `touched` as for CHANGE, their server errors and check results
@@ -1012,20 +1079,49 @@ export interface FormActions {
1012
1079
  SET: { values: Record<string, any> };
1013
1080
  /** A field lost focus (focusout): its name */
1014
1081
  BLUR: string;
1015
- /** The form element's submit (default prevented) */
1016
- SUBMIT: any;
1082
+ /** The form element's submit (default prevented); its data is ignored (a host may dispatch it from a button) */
1083
+ SUBMIT: unknown;
1017
1084
  /** Adds a row to an array field (with the next id): `{ field: 'addresses', value: { street: '' } }` */
1018
1085
  ADD: { field: string; value?: Record<string, any> };
1019
1086
  /** Removes a row by id: `{ field: 'addresses', id }` */
1020
1087
  REMOVE: { field: string; id: any };
1021
- /** Server errors: an error reply (`{ status, body: { errors } }`) or a map `{ name: message }`; focuses the first */
1022
- ERRORS: any;
1023
- /** The submit was saved: `initial` becomes `values` */
1024
- DONE: any;
1025
- /** Back to `initial` (or to the values given) */
1026
- RESET: any;
1088
+ /** Server errors: an error reply (`{ status, body: { errors } }`), a map `{ name: message }` or a list of issues; focuses the first */
1089
+ ERRORS: FormServerErrors;
1090
+ /** The submit was saved: `initial` becomes `values`. Its data (the reply) is ignored */
1091
+ DONE: unknown;
1092
+ /** PLAN-11: a progress step of the submit request (`progress: 'form.PROGRESS'` on it): kept as `progress` while submitting */
1093
+ PROGRESS: FetchProgress;
1094
+ /** Back to `initial`, or to the values given (whole values, not merged; anything but a plain object, such as a click event, means `initial`) */
1095
+ RESET: V | null | undefined;
1027
1096
  }
1028
1097
 
1098
+ /**
1099
+ * The schema's input type (Standard Schema `~standard.types.input`; zod, valibot and arktype declare
1100
+ * it); `any` for a schema that doesn't declare `types`
1101
+ */
1102
+ export type SchemaInput<S> = S extends { readonly '~standard': { readonly types?: infer T } }
1103
+ ? [NonNullable<T>] extends [{ readonly input: infer I }] ? I : any
1104
+ : any
1105
+
1106
+ type FormSchemaOutput<S> = S extends { readonly '~standard': { readonly types?: infer T } }
1107
+ ? [NonNullable<T>] extends [{ readonly output: infer O }] ? O : any
1108
+ : any
1109
+
1110
+ /** The values type of `form(schema, { values })`: the schema's input when it declares one, else what `values` gives */
1111
+ type FormValuesOf<V, S> = FormIsAny<SchemaInput<S>> extends true ? V : SchemaInput<S>
1112
+
1113
+ /** What `form()` returns: a behavior whose types carry the values (V) and the schema's output (OUT, the submit payload) */
1114
+ export type FormBehavior<V = any, OUT = any> = Behavior<FormState<V>, FormActions<V>, FormCalculated<V>, FormOptions<V, OUT>>
1115
+
1116
+ /**
1117
+ * The data the host's submit action gets (the schema's output) from a `form()` use:
1118
+ * `SIGN_UP: { HTTP: (state, data: FormOutput<typeof uses.form>) => ... }`. Or from the schema:
1119
+ * `SchemaOutput<typeof signupSchema>`. `any` for a schema that doesn't declare `types`.
1120
+ */
1121
+ export type FormOutput<F> = F extends Behavior<any, any, any, infer OPTIONS>
1122
+ ? OPTIONS extends { readonly [formOutput]?: infer O } ? (unknown extends O ? any : O) : any
1123
+ : any
1124
+
1029
1125
  /**
1030
1126
  * A form with validation (PLAN-5 F-1), any Standard Schema validator (zod, valibot, arktype, a
1031
1127
  * hand-written object):
@@ -1040,7 +1136,11 @@ export interface FormActions {
1040
1136
  * that sends nothing (STATE, PARENT, EVENTS) is done at once. Throws SYG231 when
1041
1137
  * `schema` isn't a Standard Schema.
1042
1138
  */
1043
- export function form<V = any>(schema: StandardSchemaLike, options: FormOptions<V>): Behavior<FormState<V>, FormActions, FormCalculated, FormOptions<V>>
1139
+ export function form<V = any, S extends StandardSchemaLike = StandardSchemaLike>(
1140
+ schema: S,
1141
+ options: FormOptions<V, FormSchemaOutput<S>> & { values: FormIsAny<SchemaInput<S>> extends true ? unknown : SchemaInput<S> | ((state: any) => SchemaInput<S>) }
1142
+ & ({ submit: string } | { request: FormRequest<FormSchemaOutput<S>> })
1143
+ ): FormBehavior<FormValuesOf<V, S>, FormSchemaOutput<S>>
1044
1144
 
1045
1145
  /** A Standard Schema's result for `values`: errors by field name, and the output when there are none. A Promise for an async schema. */
1046
1146
  export function checkForm(schema: StandardSchemaLike, values: any): { errors: FieldErrors; value: any } | Promise<{ errors: FieldErrors; value: any }>
@@ -2292,6 +2392,9 @@ export type FetchInit = {
2292
2392
  * descendants'): two instances, or Collection items, using the same category never see or
2293
2393
  * cancel each other's requests. The root component sees every reply.
2294
2394
  */
2395
+ /** PLAN-11: one value of a request's `multipart` body */
2396
+ export type MultipartValue = Blob | string | number | boolean | null | undefined | Record<string, any>
2397
+
2295
2398
  export type FetchRequest = string | {
2296
2399
  /** Request URL (prefixed with the driver's `baseUrl`) */
2297
2400
  url: string;
@@ -2317,8 +2420,24 @@ export type FetchRequest = string | {
2317
2420
  query?: Record<string, string | number | boolean | null | undefined | Array<string | number | boolean | null | undefined>>;
2318
2421
  /** Sent as JSON.stringify(json), with `Content-Type: application/json` unless set */
2319
2422
  json?: any;
2423
+ /**
2424
+ * PLAN-11 (6.7): a multipart/form-data body, written as data: `{ file: state.file, title: state.title }`.
2425
+ * A File or Blob is a file part, an array repeats the name (`files: [a, b]`), null/undefined are
2426
+ * skipped, an object is sent as JSON text, anything else as a string. The browser sets the
2427
+ * content-type (with its boundary); a `content-type` header is dropped. POST by default
2428
+ */
2429
+ multipart?: Record<string, MultipartValue | ReadonlyArray<MultipartValue>>;
2320
2430
  /** Raw body (string, FormData, Blob, ...) */
2321
2431
  body?: any;
2432
+ /**
2433
+ * PLAN-11 (6.7): report upload and download progress. An action name: each step arrives as that
2434
+ * action of the sending instance (`UPLOADING: (state, { phase, fraction }) => ...`); `true`: on
2435
+ * `HTTP.progress(category?)`. Data: FetchProgress. The request then goes over XMLHttpRequest
2436
+ * (the only way to see upload progress in every browser); fetch-only `init` options
2437
+ * (`keepalive`, `priority`, `cache`, `redirect`, `integrity`, `mode`, `referrerPolicy`) don't
2438
+ * apply (SYG636). Not for resources or `batch`. Without XMLHttpRequest (Node): no steps
2439
+ */
2440
+ progress?: string | true;
2322
2441
  /**
2323
2442
  * Latest only: sending this request aborts this component's requests still in flight in the
2324
2443
  * same category; their responses and errors are never delivered. Default: the driver's `latest`
@@ -2433,10 +2552,15 @@ export type FetchRetry = SocketReconnect & {
2433
2552
  count?: number;
2434
2553
  }
2435
2554
 
2436
- /** Any Standard Schema (https://standardschema.dev): an object with `~standard.validate` */
2437
- export type StandardSchemaLike = {
2555
+ /**
2556
+ * Any Standard Schema (https://standardschema.dev): an object with `~standard.validate`. `IN` / `OUT`
2557
+ * are its declared `~standard.types` (input / output; zod, valibot, arktype declare them), read by
2558
+ * `form()`, `SchemaInput` and `SchemaOutput`; a schema without `types` is `StandardSchemaLike<any>`.
2559
+ */
2560
+ export type StandardSchemaLike<IN = any, OUT = IN> = {
2438
2561
  readonly '~standard': {
2439
2562
  validate: (value: unknown) => {value?: any; issues?: ReadonlyArray<{message: string; path?: ReadonlyArray<any>}>} | Promise<{value?: any; issues?: ReadonlyArray<{message: string; path?: ReadonlyArray<any>}>}>;
2563
+ readonly types?: { readonly input: IN; readonly output: OUT } | undefined;
2440
2564
  [key: string]: any;
2441
2565
  };
2442
2566
  }
@@ -2563,11 +2687,30 @@ export type FetchError<REQUEST = any> = {
2563
2687
  body?: any;
2564
2688
  }
2565
2689
 
2690
+ /**
2691
+ * PLAN-11 (6.7): one progress step of a request sent with `progress`. Steps come at most every
2692
+ * 50 ms per phase, always including the first and the last (`fraction: 1`). A request with a
2693
+ * body reports 'upload' then 'download'; a GET only 'download'. A retry starts again at 0.
2694
+ */
2695
+ export type FetchProgress<REQUEST = any> = {
2696
+ phase: 'upload' | 'download';
2697
+ /** Bytes sent ('upload') or received ('download') so far */
2698
+ loaded: number;
2699
+ /** Total bytes, or null when unknown (a download without Content-Length) */
2700
+ total: number | null;
2701
+ /** loaded / total (0..1), or null when the total is unknown */
2702
+ fraction: number | null;
2703
+ category: string | undefined;
2704
+ request: REQUEST;
2705
+ }
2706
+
2566
2707
  export type FetchSource<VALUE = any> = {
2567
2708
  /** 2xx responses: all of them, one category, or those a predicate accepts */
2568
2709
  select: (category?: string | ((response: FetchResponse<VALUE>) => boolean)) => Stream<FetchResponse<VALUE>>
2569
2710
  /** Failures. Filters like select(). While nothing listens, failures are console.error'd */
2570
2711
  errors: (category?: string | ((failure: FetchError) => boolean)) => Stream<FetchError>
2712
+ /** PLAN-11: progress steps of requests sent with `progress: true`. Filters like select() */
2713
+ progress: (category?: string | ((step: FetchProgress) => boolean)) => Stream<FetchProgress>
2571
2714
  }
2572
2715
 
2573
2716
  export type FetchDriverOptions = {
@@ -2586,8 +2729,10 @@ export type FetchDriverOptions = {
2586
2729
  timeoutMs?: number;
2587
2730
  /** Default `parse` for every request. Default 'auto' */
2588
2731
  parse?: 'auto' | 'json' | 'text' | 'response' | ((response: Response) => any);
2589
- /** The fetch implementation. Default: `globalThis.fetch`, read at each request (so test stubs apply) */
2732
+ /** The fetch implementation. Default: `globalThis.fetch`, read at each request (so test stubs apply). Not used for `progress` requests */
2590
2733
  fetch?: (input: string, init?: any) => Promise<any>;
2734
+ /** PLAN-11: the XMLHttpRequest class for `progress` requests. Default: the global one, read at each request */
2735
+ XMLHttpRequest?: any;
2591
2736
  /**
2592
2737
  * PLAN-3 D88: the query cache, off by default: `cache: queryCache({ staleTime })`. On:
2593
2738
  * resources' GET/HEAD replies are cached (stale-while-revalidate: cached data shows at once,
@@ -2670,6 +2815,12 @@ export type SseConnection = SocketActions & {
2670
2815
  /** URL (after `baseUrl`) */
2671
2816
  sse: string;
2672
2817
  withCredentials?: boolean;
2818
+ /**
2819
+ * PLAN-11 (6.7): request headers, e.g. `{ authorization: 'Bearer ' + state.token }`. The native
2820
+ * EventSource can't send headers, so a connection with them is read with `fetch` (same events,
2821
+ * reconnects with `Last-Event-ID`). A changed header (a new token in state) reconnects
2822
+ */
2823
+ headers?: Record<string, string>;
2673
2824
  /** Named events → actions: `{ 'price-update': 'PRICE' }` (data JSON-parsed when it parses) */
2674
2825
  events?: Record<string, string>;
2675
2826
  }
@@ -2722,8 +2873,10 @@ export type SocketDriverOptions = {
2722
2873
  queueLimit?: number;
2723
2874
  /** The WebSocket class. Default: `globalThis.WebSocket`, read at connect time (so test stubs apply) */
2724
2875
  WebSocket?: any;
2725
- /** The EventSource class. Default: `globalThis.EventSource`, read at connect time */
2876
+ /** The EventSource class. Default: `globalThis.EventSource`, read at connect time (an `sse` connection with `headers`: a fetch-based one, unless this is given) */
2726
2877
  EventSource?: any;
2878
+ /** PLAN-11: the fetch for `sse` connections with `headers`. Default: `globalThis.fetch` */
2879
+ fetch?: (input: string, init?: any) => Promise<any>;
2727
2880
  }
2728
2881
 
2729
2882
  /**
@@ -3147,6 +3300,12 @@ export type FakeCacheEntry = {
3147
3300
  }
3148
3301
 
3149
3302
  export interface RenderOptions {
3303
+ /**
3304
+ * 7.0 timing audit (SYG257, info): report each output sink that reads a top-level state field
3305
+ * its action's STATE changed (it gets the new value since 7.0; 6.x gave it the old one). For
3306
+ * migrating 6.x code; default off (configureTimingAudit() from 'sygnal/diagnostics' sets it for all)
3307
+ */
3308
+ timingAudit?: boolean;
3150
3309
  /** Override initial state (defaults to component's .initialState) */
3151
3310
  initialState?: any;
3152
3311
  /**
@@ -3472,6 +3631,14 @@ export interface RenderResult<STATE = any> {
3472
3631
  * throws (the driver would only log it, SYG678).
3473
3632
  */
3474
3633
  fail: (sinkName: string, error: any, target?: string | FakeReplyOptions | Record<string, any> | ((request: any) => boolean)) => Promise<void>;
3634
+ /**
3635
+ * PLAN-11 (6.7): one progress step of a pending request sent with `progress` (chosen as in
3636
+ * respond; `'UPLOADING'` matches its progress action too): `t.progress('HTTP', { loaded: 50, total: 100 }, 'UPLOADING')`.
3637
+ * The component gets FetchProgress (`fraction: 0.5`) as that action, or on `HTTP.progress()`
3638
+ * for `progress: true`. `phase` defaults to 'upload' for a request with a body, else
3639
+ * 'download'. The request stays pending until respond()/fail(). Resolves once reduced and rendered.
3640
+ */
3641
+ progress: (sinkName: string, step: { loaded: number; total?: number; phase?: 'upload' | 'download' }, target?: string | FakeReplyOptions | Record<string, any> | ((request: any) => boolean)) => Promise<void>;
3475
3642
  /**
3476
3643
  * PLAN-6 L-4: stream into a pending request of the LLM fake (the sink named by `llmSink`,
3477
3644
  * default 'LLM': the real makeChatDriver over an in-memory transport). Chunks: strings (text),
@@ -3603,13 +3770,13 @@ export interface RenderResult<STATE = any> {
3603
3770
  * `await t.next(pred)` / `waitForState` / `settle()` / `ready()` it shows the state the wait resolved with.
3604
3771
  */
3605
3772
  query: {
3606
- (selector: string): Element | null;
3773
+ <E extends Element = Element>(selector: string): E | null;
3607
3774
  /** A control's element (`t.query(Draft)` is an HTMLInputElement for an 'input' control), or null */
3608
3775
  <CONTROL extends AnyControl>(control: CONTROL): ControlElementOf<CONTROL> | null;
3609
3776
  };
3610
3777
  /** Every element matching a selector (or a control) in the rendered tree (Portals included), as query() */
3611
3778
  queryAll: {
3612
- (selector: string): Element[];
3779
+ <E extends Element = Element>(selector: string): E[];
3613
3780
  <CONTROL extends AnyControl>(control: CONTROL): Array<ControlElementOf<CONTROL>>;
3614
3781
  };
3615
3782
  /**
package/src/index.ts CHANGED
@@ -86,3 +86,4 @@ export * from './cycle/dom/index'
86
86
  // and ESM classes tree-shake when an app does not use them.
87
87
  export { concat, debounce, throttle, delay, dropRepeats, sampleCombine } from './extra/xstreamExtras'
88
88
  export { flattenConcurrently, flattenSequentially } from './extra/flatten'
89
+
package/src/shared.ts CHANGED
@@ -55,3 +55,6 @@ export const NOT_SINK = /^(__|(props|children|dispose|commands)\$$|(CHILD|PARENT
55
55
  */
56
56
  export const abortReason: {r?: string} = {}
57
57
  export const abort = (reason?: string): typeof ABORT => (abortReason.r = reason, ABORT)
58
+
59
+ /** Private semantic binding identity, shared by built-in helpers and the runtime. */
60
+ export const BINDING = /*#__PURE__*/ Symbol.for('sygnal.binding')
package/src/ui/dialog.ts CHANGED
@@ -110,9 +110,10 @@ const base = /*#__PURE__*/ defineBehavior({
110
110
  TOGGLED: (s: any, open: boolean) => (open && !s.open ? {...s, open, returnValue: ''} : ABORT),
111
111
  CLOSED: {
112
112
  STATE: (s: any, returnValue: string) => (!s.open && s.returnValue === returnValue ? ABORT : {...s, open: false, returnValue}),
113
- ELEMENT: (s: any, _d: any, _n: any, _p: any, o: any) => {
113
+ ELEMENT: (s: any, _d: any, _n: any, p: any, o: any, k: string) => {
114
114
  const f = o.returnFocus ?? true, sel = f === true ? o.trigger : f
115
- if (!s.open || !sel) return ABORT
115
+ // only a dialog that was open before this close returns the focus
116
+ if (!(p?.previousState ? p.previousState[k] : s).open || !sel) return ABORT
116
117
  // true: the element that opened it (OPEN's close listener, G-430), else the trigger; a
117
118
  // selector: that element (each only when the focus was lost)
118
119
  return {focus: refocus(sel)}