@wcstack/contacts 1.20.0 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.esm.js CHANGED
@@ -42,23 +42,403 @@ function setConfig(partialConfig) {
42
42
  frozenConfig = null;
43
43
  }
44
44
 
45
+ // ===========================================================================
46
+ // AUTO-GENERATED FILE - DO NOT EDIT.
47
+ // Generated from /io-core/operation-lane.ts by scripts/sync-io-core.mjs.
48
+ // Run `node scripts/sync-io-core.mjs` after editing the source.
49
+ // ===========================================================================
50
+ /**
51
+ * 1 レーン = 独立した排他単位。Core が 1 つ以上所有する (module singleton にしない —
52
+ * 複数 <wcs-fetch> 間で漏れるため)。
53
+ */
54
+ class OperationLane {
55
+ laneKey;
56
+ policy;
57
+ _ownerGeneration = 0;
58
+ _latestEpoch = 0;
59
+ _nextOperationId = 1;
60
+ // latest / queue / exhaust の単一 active。queue は head を指す。
61
+ _activeOperationId = undefined;
62
+ // overlap の active set (§5: 内部 bookkeeping のみ・observable 公開はしない)。
63
+ _activeOperationIds = new Set();
64
+ // queue policy の FIFO。
65
+ _queue = [];
66
+ _inFlightCount = 0;
67
+ // opId → 終端状態 (absence = pending)。
68
+ _terminal = new Map();
69
+ // claimTerminal で確定した outcome (finalize が 'committing' を最終値へ移す)。
70
+ _claimedOutcome = new Map();
71
+ // opId → AbortController (identity は opId が保証。cross-op clobber は構造上起きない)。
72
+ _controllers = new Map();
73
+ // opId → attempt 数。
74
+ _attempts = new Map();
75
+ _withSignal;
76
+ _trace;
77
+ constructor(laneKey, policy, options = {}) {
78
+ this.laneKey = laneKey;
79
+ this.policy = policy;
80
+ this._withSignal = options.withSignal ?? false;
81
+ this._trace = options.trace;
82
+ }
83
+ get ownerGeneration() {
84
+ return this._ownerGeneration;
85
+ }
86
+ get inFlightCount() {
87
+ return this._inFlightCount;
88
+ }
89
+ get latestEpoch() {
90
+ return this._latestEpoch;
91
+ }
92
+ get activeOperationId() {
93
+ return this._activeOperationId;
94
+ }
95
+ /**
96
+ * 新しい要求の到着。arrival policy を適用し ticket + 最初の attempt を発行する。
97
+ * exhaust で実行中の場合だけ null を返す (新要求を ticket 化せず拒否 = 冪等 no-op)。
98
+ */
99
+ begin() {
100
+ let supersedeEpoch;
101
+ switch (this.policy) {
102
+ case "latest": {
103
+ // latestEpoch を進め、旧 active を abort (可能なら)。旧 ticket は settle 時に
104
+ // eligibility 不一致で stale となる。
105
+ supersedeEpoch = ++this._latestEpoch;
106
+ if (this._activeOperationId !== undefined) {
107
+ this._abortController(this._activeOperationId);
108
+ }
109
+ break;
110
+ }
111
+ case "exhaust": {
112
+ // 実行中なら新要求を拒否 (呼び出し側は既存結果へ合流)。
113
+ if (this._activeOperationId !== undefined) {
114
+ return null;
115
+ }
116
+ break;
117
+ }
118
+ }
119
+ const operationId = this._nextOperationId++;
120
+ const ticket = {
121
+ operationId,
122
+ ownerGeneration: this._ownerGeneration,
123
+ laneKey: this.laneKey,
124
+ policy: this.policy,
125
+ supersedeEpoch,
126
+ };
127
+ switch (this.policy) {
128
+ case "latest":
129
+ case "exhaust":
130
+ this._activeOperationId = operationId;
131
+ break;
132
+ case "queue":
133
+ this._queue.push(ticket);
134
+ // 先頭だけを active にする (先行が完了するまで待つ)。
135
+ if (this._activeOperationId === undefined) {
136
+ this._activeOperationId = operationId;
137
+ }
138
+ break;
139
+ case "overlap":
140
+ this._activeOperationIds.add(operationId);
141
+ break;
142
+ }
143
+ this._inFlightCount += 1;
144
+ this._attempts.set(operationId, 1);
145
+ const attempt = this._makeAttempt(operationId, 1);
146
+ if (this._trace !== undefined) {
147
+ this._trace({ type: "io:operation-started", operationId, laneKey: this.laneKey, policy: this.policy });
148
+ }
149
+ return { ticket, attempt };
150
+ }
151
+ /**
152
+ * retry: 同じ operationId に新しい attempt を作る。attempt number と resource signal
153
+ * だけを更新する (§5)。既に終端した operation には作れない (null)。
154
+ */
155
+ retry(ticket) {
156
+ if (ticket.ownerGeneration !== this._ownerGeneration)
157
+ return null;
158
+ if (this._terminal.has(ticket.operationId))
159
+ return null;
160
+ const previous = this._attempts.get(ticket.operationId);
161
+ if (previous === undefined)
162
+ return null;
163
+ const attemptNo = previous + 1;
164
+ this._attempts.set(ticket.operationId, attemptNo);
165
+ // 前の attempt の signal は破棄し、新しい controller を張る。
166
+ this._releaseController(ticket.operationId);
167
+ const attempt = this._makeAttempt(ticket.operationId, attemptNo);
168
+ if (this._trace !== undefined) {
169
+ this._trace({ type: "io:operation-retried", operationId: ticket.operationId, laneKey: this.laneKey, attempt: attemptNo });
170
+ }
171
+ return attempt;
172
+ }
173
+ /**
174
+ * CommitGuard (§5.1)。外部可視の setter / event dispatch の直前に呼ぶ。
175
+ * (1) owner lifecycle generation 一致 (2) terminal settle 前 (3) policy eligibility。
176
+ */
177
+ canCommit(ticket) {
178
+ if (ticket.ownerGeneration !== this._ownerGeneration)
179
+ return false;
180
+ const status = this._terminal.get(ticket.operationId);
181
+ // absence = pending / 'committing' = multi-setter commit 中。どちらも settle 前。
182
+ if (status !== undefined && status !== "committing")
183
+ return false;
184
+ return this._isEligible(ticket);
185
+ }
186
+ /**
187
+ * terminal CAS (§5.1): pending → committing を claim する。勝者だけが true。
188
+ * eligibility / owner gen を満たさない場合も false。claim 後は commit 中となり、
189
+ * canCommit は各 setter の直前で再検査する (setter が同期 supersede しても取りこぼさない)。
190
+ */
191
+ claimTerminal(ticket, outcome) {
192
+ if (ticket.ownerGeneration !== this._ownerGeneration)
193
+ return false;
194
+ if (this._terminal.has(ticket.operationId))
195
+ return false; // 既に committing / 終端
196
+ if (!this._isEligible(ticket))
197
+ return false;
198
+ this._terminal.set(ticket.operationId, "committing");
199
+ this._claimedOutcome.set(ticket.operationId, outcome);
200
+ return true;
201
+ }
202
+ /** claim 済み outcome (timer が claim → catch が読む等)。未 claim なら undefined。 */
203
+ claimedOutcome(ticket) {
204
+ return this._claimedOutcome.get(ticket.operationId);
205
+ }
206
+ /**
207
+ * operation の後始末。claim 済みなら outcome を確定し、未 claim なら stale-drop。
208
+ * controller を解放し in-flight を減らし、policy の bookkeeping を進める。冪等。
209
+ */
210
+ finalize(ticket) {
211
+ const operationId = ticket.operationId;
212
+ const status = this._terminal.get(operationId);
213
+ if (status !== undefined && status !== "committing") {
214
+ // 既に確定済み。冪等に return。
215
+ return;
216
+ }
217
+ let outcome;
218
+ if (status === "committing") {
219
+ outcome = this._claimedOutcome.get(operationId) ?? "stale";
220
+ }
221
+ else {
222
+ // 一度も claim されなかった (supersede / dispose で eligibility を失った)。
223
+ outcome = "stale";
224
+ }
225
+ this._terminal.set(operationId, outcome);
226
+ this._claimedOutcome.delete(operationId);
227
+ this._releaseController(operationId);
228
+ this._attempts.delete(operationId);
229
+ if (this._inFlightCount > 0)
230
+ this._inFlightCount -= 1;
231
+ this._advanceBookkeeping(operationId);
232
+ if (this._trace !== undefined) {
233
+ if (outcome === "stale") {
234
+ this._trace({ type: "io:stale-dropped", operationId, laneKey: this.laneKey });
235
+ }
236
+ else {
237
+ this._trace({ type: "io:operation-settled", operationId, laneKey: this.laneKey, outcome });
238
+ }
239
+ }
240
+ }
241
+ /** operation の signal (resource 解放用)。withSignal でなければ undefined。 */
242
+ signalOf(ticket) {
243
+ return this._controllers.get(ticket.operationId)?.signal;
244
+ }
245
+ /** best-effort な resource 中断。正しさは owner gen / eligibility / terminal CAS が担う。 */
246
+ abort(ticket) {
247
+ this._abortController(ticket.operationId);
248
+ }
249
+ /**
250
+ * 現在 active な operation を中断する (利用者による明示キャンセル)。epoch は進めない —
251
+ * 中断された operation は eligibility を保ったまま 'aborted' を claim できる
252
+ * (loading をクリアしつつ in-flight 状態を残す)。
253
+ */
254
+ abortActive() {
255
+ if (this._activeOperationId !== undefined) {
256
+ this._abortController(this._activeOperationId);
257
+ }
258
+ for (const operationId of this._activeOperationIds) {
259
+ this._abortController(operationId);
260
+ }
261
+ }
262
+ /**
263
+ * dispose (§4.1 world generation)。owner generation を bump して全 ticket を無効化し、
264
+ * 生きている controller を全て abort する。dispose 後に settle した operation は
265
+ * owner gen 不一致で外部 commit しない。retention gate (§10.3) のため live な
266
+ * 全 operation を即時に stale として finalize し、controller / attempt を解放する。
267
+ */
268
+ disposeOwner() {
269
+ this._ownerGeneration += 1;
270
+ for (const operationId of Array.from(this._controllers.keys())) {
271
+ this._abortController(operationId);
272
+ // finalize は dispose 後 (terminal='stale') に early-return するため controller を
273
+ // 解放しない。retention gate (§10.3) を満たすためここで明示的に解放する。
274
+ this._releaseController(operationId);
275
+ }
276
+ for (const operationId of Array.from(this._attempts.keys())) {
277
+ if (!this._terminal.has(operationId)) {
278
+ this._terminal.set(operationId, "stale");
279
+ }
280
+ this._claimedOutcome.delete(operationId);
281
+ this._attempts.delete(operationId);
282
+ if (this._trace !== undefined) {
283
+ this._trace({ type: "io:stale-dropped", operationId, laneKey: this.laneKey });
284
+ }
285
+ }
286
+ this._activeOperationId = undefined;
287
+ this._activeOperationIds.clear();
288
+ this._queue.length = 0;
289
+ this._inFlightCount = 0;
290
+ }
291
+ // --- internal ---
292
+ _makeAttempt(operationId, attemptNo) {
293
+ let signal;
294
+ // AbortController 不在環境(古い runtime / 一部 SSR)では degraded: signal なしで進む。
295
+ // 正しさは owner generation / eligibility / terminal CAS が担うため、native 中断が
296
+ // 無くても supersede / dispose は機能する(best-effort resource 中断が省かれるだけ)。
297
+ if (this._withSignal && typeof AbortController === "function") {
298
+ const controller = new AbortController();
299
+ this._controllers.set(operationId, controller);
300
+ signal = controller.signal;
301
+ }
302
+ return { operationId, attempt: attemptNo, signal };
303
+ }
304
+ _isEligible(ticket) {
305
+ switch (this.policy) {
306
+ case "latest":
307
+ return ticket.supersedeEpoch === this._latestEpoch;
308
+ case "queue":
309
+ case "exhaust":
310
+ return this._activeOperationId === ticket.operationId;
311
+ case "overlap":
312
+ return this._activeOperationIds.has(ticket.operationId);
313
+ }
314
+ }
315
+ _advanceBookkeeping(operationId) {
316
+ switch (this.policy) {
317
+ case "latest":
318
+ case "exhaust":
319
+ if (this._activeOperationId === operationId) {
320
+ this._activeOperationId = undefined;
321
+ }
322
+ break;
323
+ case "queue": {
324
+ // 完了した ticket を FIFO から取り除き、次の先頭を active にする。filter で
325
+ // 「先頭 / 非先頭 / 不在」を一様に扱う (finalize は冪等ガードを通った op のみ到達)。
326
+ const remaining = this._queue.filter((t) => t.operationId !== operationId);
327
+ this._queue.length = 0;
328
+ this._queue.push(...remaining);
329
+ this._activeOperationId = this._queue.length > 0 ? this._queue[0].operationId : undefined;
330
+ break;
331
+ }
332
+ case "overlap":
333
+ this._activeOperationIds.delete(operationId);
334
+ break;
335
+ }
336
+ }
337
+ _abortController(operationId) {
338
+ const controller = this._controllers.get(operationId);
339
+ if (controller !== undefined && !controller.signal.aborted) {
340
+ controller.abort();
341
+ }
342
+ }
343
+ _releaseController(operationId) {
344
+ this._controllers.delete(operationId);
345
+ }
346
+ }
347
+
348
+ // ===========================================================================
349
+ // AUTO-GENERATED FILE - DO NOT EDIT.
350
+ // Generated from /io-core/platform-capability.ts by scripts/sync-io-core.mjs.
351
+ // Run `node scripts/sync-io-core.mjs` after editing the source.
352
+ // ===========================================================================
353
+ function isSecureContext() {
354
+ return globalThis.isSecureContext === true;
355
+ }
356
+ /**
357
+ * capability を利用直前に評価して PlatformAssessment を作る。
358
+ * required が 1 つでも欠ければ readiness は "idle"(開始不可)、
359
+ * required 揃い + optional 欠けは "degraded"、全揃いは "ready"。
360
+ */
361
+ function assessCapabilities(registry, options) {
362
+ const availability = new Map();
363
+ const evaluate = (id) => {
364
+ const spec = registry.get(id);
365
+ if (spec === undefined)
366
+ return "unknown";
367
+ return spec.probe() ? "available" : "missing";
368
+ };
369
+ let requiredAllAvailable = true;
370
+ for (const id of options.required) {
371
+ const a = evaluate(id);
372
+ availability.set(id, a);
373
+ if (a !== "available")
374
+ requiredAllAvailable = false;
375
+ }
376
+ let optionalAllAvailable = true;
377
+ for (const id of options.optional ?? []) {
378
+ const a = evaluate(id);
379
+ availability.set(id, a);
380
+ if (a !== "available")
381
+ optionalAllAvailable = false;
382
+ }
383
+ const readiness = !requiredAllAvailable ? "idle" : (optionalAllAvailable ? "ready" : "degraded");
384
+ // preconditions: 対象 capability のいずれかが要求する場合だけ評価する。
385
+ const allIds = [...options.required, ...(options.optional ?? [])];
386
+ const needsSecure = allIds.some((id) => registry.get(id)?.requiresSecureContext === true);
387
+ const needsActivation = allIds.some((id) => registry.get(id)?.requiresUserActivation === true);
388
+ const secureContext = needsSecure ? (isSecureContext() ? "satisfied" : "required") : "not-applicable";
389
+ const userActivation = needsActivation ? "required" : "not-applicable";
390
+ return {
391
+ availability,
392
+ permission: options.permission ?? "not-applicable",
393
+ readiness,
394
+ activity: options.activity ?? "inactive",
395
+ preconditions: { secureContext, userActivation },
396
+ epoch: options.epoch ?? 0,
397
+ lastError: options.lastError,
398
+ };
399
+ }
400
+ /** availability から「required がすべて available か」を判定するヘルパ(supported の最低条件)。 */
401
+ function requiredCapabilitiesAvailable(assessment, required) {
402
+ return required.every((id) => assessment.availability.get(id) === "available");
403
+ }
404
+
405
+ /**
406
+ * contactsCapabilities.ts
407
+ *
408
+ * Contact Picker node 固有の capability registry と error code。汎用の assess 機構・
409
+ * 型は `./platformCapability.js`(/io-core/ から copy-distribution される生成ファイル)
410
+ * から import する。node 固有の宣言はこのハンドライトファイルに置き、生成コピーとは
411
+ * 分離する。
412
+ */
413
+ /** 安定した contacts error code(taxonomy)。値は公開キーとして固定。 */
414
+ const WCS_CONTACTS_ERROR_CODE = {
415
+ CapabilityMissing: "capability-missing",
416
+ SelectFailed: "select-failed",
417
+ };
418
+ /** contacts node の capability registry。文字列 ID を eval せず明示 probe を持つ。 */
419
+ const CONTACTS_CAPABILITIES = new Map([
420
+ ["web.contacts", { probe: () => typeof globalThis.navigator?.contacts?.select === "function", compatKey: "api.ContactsManager.select" }],
421
+ ]);
422
+
45
423
  /**
46
424
  * Headless Contact Picker primitive. A thin, framework-agnostic wrapper around
47
425
  * `navigator.contacts.select(properties, options)` exposed through the
48
426
  * wc-bindable protocol.
49
427
  *
50
- * This is the same simplified derivative of `FetchCore._doFetch` that
51
- * `@wcstack/share`'s `ShareCore` establishes (docs/contact-picker-tag-design.md
52
- * §1): single `_gen` generation guard, same-value-guarded private setters,
53
- * never-throw try/catch, no `AbortController`/`abort()` the Contact Picker
54
- * API accepts no `AbortSignal` and, like the Web Share dialog, the picker is a
55
- * single system-modal surface (at most one open at a time).
428
+ * Concurrency is owned by the shared `OperationLane` (io-core) with the `exhaust`
429
+ * policy: the contact picker is a single system-modal surface, so while one
430
+ * select() is in flight a new call is rejected as an idempotent no-op instead of
431
+ * starting a second `navigator.contacts.select()`. This replaces the earlier
432
+ * dispose-only `_gen` guard, which relied on the platform rejecting the second call
433
+ * with `InvalidStateError` but that let the rejected second call reset/overwrite
434
+ * the still-pending first call's `error`/`loading` state. The lane's owner
435
+ * generation still invalidates any in-flight select() on dispose().
56
436
  *
57
- * The one structural difference from `ShareCore`: `select()` takes **two**
58
- * positional arguments (`properties`, `options`) rather than one — the first
59
- * batch-3 member to do so. The command-token argument pass-through
60
- * (spec-proposal-command-token-arguments.md) does not special-case argument
61
- * count, so this requires no protocol change.
437
+ * The Contact Picker API accepts no `AbortSignal`, so the lane runs with
438
+ * `withSignal: false`. `select()` takes **two** positional arguments
439
+ * (`properties`, `options`) rather than one the command-token argument
440
+ * pass-through does not special-case argument count, so this requires no protocol
441
+ * change.
62
442
  */
63
443
  class ContactsCore extends EventTarget {
64
444
  static wcBindable = {
@@ -69,30 +449,28 @@ class ContactsCore extends EventTarget {
69
449
  { name: "loading", event: "wcs-contacts:loading-changed" },
70
450
  { name: "error", event: "wcs-contacts:error" },
71
451
  { name: "cancelled", event: "wcs-contacts:cancelled-changed" },
452
+ // Serializable failure taxonomy (stable code / phase / recoverable), or null.
453
+ // Additive bindable output; the existing `error` property/event are unchanged.
454
+ // Fires its own `wcs-contacts:error-info-changed` event; no getter, so the
455
+ // bound value is the event detail (mirrors `error` / `loading` / `cancelled`).
456
+ { name: "errorInfo", event: "wcs-contacts:error-info-changed" },
72
457
  ],
73
458
  commands: [
74
459
  { name: "select", async: true },
75
460
  ],
76
461
  };
462
+ // Required capability (probed at call time, never at module eval).
463
+ static REQUIRED_CAPABILITIES = ["web.contacts"];
77
464
  _target;
78
465
  _value = null;
79
466
  _loading = false;
80
467
  _error = null;
81
468
  _cancelled = false;
82
- // Generation guard (§3.4 of the guidelines): bumped ONLY by dispose(). A
83
- // select() that settles after dispose() has a stale `gen` and MUST NOT
84
- // write state to a torn-down element. Unlike FetchCore/EyedropperCore,
85
- // select() itself does NOT bump `_gen` on each call: the archetype
86
- // (docs/web-share-tag-design.md §2, adopted verbatim by
87
- // docs/contact-picker-tag-design.md §1) deliberately drops the "a new call
88
- // supersedes the previous one" plumbing those cores need, because the
89
- // contact picker is a single system-modal surface (a second concurrent
90
- // select() rejects with InvalidStateError on its own). Bumping `_gen` per
91
- // call would instead let a fast-failing second call incorrectly invalidate
92
- // a still-pending first call's eventual success. Also not bumped on the
93
- // unsupported early-return — no asynchronous work is started, so there is
94
- // no generation to protect.
95
- _gen = 0;
469
+ _errorInfo = null;
470
+ // Concurrency lane (io-core). `exhaust`: only one picker at a time a new begin()
471
+ // while active returns null (idempotent no-op). `withSignal: false`:
472
+ // navigator.contacts.select() has no AbortSignal. dispose() bumps the owner gen.
473
+ _lane = new OperationLane("contacts", "exhaust", { withSignal: false });
96
474
  // SSR (§3.8): no asynchronous probe to await, so readiness is immediate.
97
475
  _ready = Promise.resolve();
98
476
  constructor(target) {
@@ -114,15 +492,43 @@ class ContactsCore extends EventTarget {
114
492
  get cancelled() {
115
493
  return this._cancelled;
116
494
  }
117
- // Lifecycle (§3.5). Select is command-driven with no subscription to
118
- // establish, so observe() is an idempotent no-op that resolves once ready;
119
- // dispose() only invalidates any in-flight select() (there is nothing to
120
- // abort or unsubscribe).
495
+ /**
496
+ * The last failure's serializable `WcsIoErrorInfo` (stable `code` / `phase` /
497
+ * `recoverable` / `capabilityId`), or null. Exposed as an additive wc-bindable
498
+ * property (event `wcs-contacts:error-info-changed`); the existing `error`
499
+ * property/event are unchanged.
500
+ */
501
+ get errorInfo() {
502
+ return this._errorInfo;
503
+ }
504
+ /**
505
+ * Whether the required platform capability (`web.contacts`) is available right
506
+ * now — decided by call-time feature detection, not User-Agent. Core-only,
507
+ * additive.
508
+ */
509
+ get supported() {
510
+ return requiredCapabilitiesAvailable(this.platformAssessment, ContactsCore.REQUIRED_CAPABILITIES);
511
+ }
512
+ /**
513
+ * Full platform assessment (availability / readiness / preconditions), probed at
514
+ * call time. Core-only opt-in dev / sidecar view.
515
+ */
516
+ get platformAssessment() {
517
+ return assessCapabilities(CONTACTS_CAPABILITIES, {
518
+ required: ContactsCore.REQUIRED_CAPABILITIES,
519
+ activity: this._loading ? "active" : "inactive",
520
+ lastError: this._errorInfo ?? undefined,
521
+ });
522
+ }
523
+ // Lifecycle (§3.5). Select is command-driven with no subscription to establish,
524
+ // so observe() is an idempotent no-op that resolves once ready; dispose() bumps
525
+ // the lane's owner generation, invalidating any in-flight select() (a late
526
+ // resolve then fails the terminal CAS). There is nothing to abort or unsubscribe.
121
527
  observe() {
122
528
  return this._ready;
123
529
  }
124
530
  dispose() {
125
- this._gen++;
531
+ this._lane.disposeOwner();
126
532
  }
127
533
  _setLoading(loading) {
128
534
  if (this._loading === loading)
@@ -160,56 +566,88 @@ class ContactsCore extends EventTarget {
160
566
  bubbles: true,
161
567
  }));
162
568
  }
163
- // API resolution is call-time, never cached (§3.7): lets tests install/remove
164
- // navigator.contacts freely and lets an unsupported environment (the common
165
- // case desktop browsers entirely lack this API) be detected correctly on
166
- // every call.
167
- _api() {
168
- const nav = globalThis.navigator;
169
- return typeof nav?.contacts?.select === "function" ? nav.contacts.select.bind(nav.contacts) : undefined;
569
+ // Single mutation point for `errorInfo`, mirroring `_setError`'s same-value guard
570
+ // and event dispatch so the additive `errorInfo` wc-bindable property stays in
571
+ // sync with `error`. Each failure builds a fresh object (reference guard passes);
572
+ // the clear path passes null (suppresses a redundant null→null per select start).
573
+ _setErrorInfo(code, phase, recoverable, message, capabilityId) {
574
+ this._commitErrorInfo({ code, phase, recoverable, message, ...(capabilityId === undefined ? {} : { capabilityId }) });
575
+ }
576
+ _commitErrorInfo(info) {
577
+ if (this._errorInfo === info)
578
+ return;
579
+ this._errorInfo = info;
580
+ this._target.dispatchEvent(new CustomEvent("wcs-contacts:error-info-changed", {
581
+ detail: info,
582
+ bubbles: true,
583
+ }));
170
584
  }
171
585
  async select(properties, options) {
172
- // never-throw + unsupported: resolve API at call time and bail out
173
- // immediately if absent. No _gen bump no asynchronous work is started.
174
- const selectFn = this._api();
175
- if (!selectFn) {
176
- this._setError({ message: "Contact Picker API is not supported in this browser." });
586
+ // never-throw + unsupported (§7.2): probe the required capability at call time.
587
+ // Desktop browsers entirely lack this API, so this is the common case. If
588
+ // `web.contacts` is absent, do NOT start — surface a stable `capability-missing`
589
+ // taxonomy and the existing error message shape.
590
+ const assessment = this.platformAssessment;
591
+ if (!requiredCapabilitiesAvailable(assessment, ContactsCore.REQUIRED_CAPABILITIES)) {
592
+ const missing = ContactsCore.REQUIRED_CAPABILITIES.find((id) => assessment.availability.get(id) !== "available");
593
+ const message = "Contact Picker API is not supported in this browser.";
594
+ this._setErrorInfo(WCS_CONTACTS_ERROR_CODE.CapabilityMissing, "start", false, message, missing);
595
+ this._setError({ message });
177
596
  return null;
178
597
  }
179
- // Captured, not bumped (see the `_gen` field docs above): select() does
180
- // not supersede a prior in-flight call, only dispose() invalidates.
181
- const gen = this._gen;
598
+ // exhaust: a picker is already open reject this call as an idempotent no-op
599
+ // instead of racing a second select() (which would reject and corrupt the
600
+ // in-flight call's result). begin() returns null when active.
601
+ const started = this._lane.begin();
602
+ if (started === null) {
603
+ return null;
604
+ }
605
+ const { ticket } = started;
606
+ // Capability probed above → navigator.contacts.select is present. Resolve + bind
607
+ // at call time (never cached, §3.7) so tests can install/remove it freely.
608
+ const nav = globalThis.navigator;
609
+ const selectFn = nav.contacts.select.bind(nav.contacts);
610
+ // Start phase runs synchronously on a fresh ticket (no dispose can interleave
611
+ // before the first await), so these setters are unconditional. Post-await
612
+ // setters are gated by the lane's terminal CAS (claimTerminal), which fails on
613
+ // a stale/disposed ticket — contacts (exhaust, no supersede/timeout) needs no
614
+ // per-setter commit guard (unlike FetchCore's `latest` lane).
182
615
  this._setLoading(true);
183
616
  // Reset the previous outcome before starting a new select so a stale
184
- // cancelled/error does not linger into this call's result.
617
+ // cancelled/error/errorInfo does not linger into this call's result.
618
+ this._commitErrorInfo(null);
185
619
  this._setError(null);
186
620
  this._setCancelled(false);
187
621
  try {
188
622
  const contacts = await selectFn(properties, options);
189
- // Stale completion (dispose() ran while the picker was open).
190
- if (gen !== this._gen) {
623
+ // Terminal CAS: a stale (dispose-invalidated) completion loses the claim.
624
+ if (!this._lane.claimTerminal(ticket, "success")) {
191
625
  return null;
192
626
  }
193
- // `multiple` does not change the result shape — even a single
194
- // selection resolves to a one-element array (docs/contact-picker-tag-design.md §3).
627
+ // `multiple` does not change the result shape — even a single selection
628
+ // resolves to a one-element array (docs/contact-picker-tag-design.md §3).
195
629
  this._setValue(contacts);
196
630
  this._setLoading(false);
631
+ this._lane.finalize(ticket);
197
632
  return contacts;
198
633
  }
199
634
  catch (e) {
200
- // Stale completion (dispose() ran while the picker was open).
201
- if (gen !== this._gen) {
635
+ const cancelled = e?.name === "AbortError";
636
+ if (!this._lane.claimTerminal(ticket, cancelled ? "aborted" : "error")) {
202
637
  return null;
203
638
  }
204
- if (e?.name === "AbortError") {
205
- // The user dismissed the contact picker — a routine cancellation, not
206
- // a platform failure. Kept out of `error`.
639
+ if (cancelled) {
640
+ // The user dismissed the contact picker — a routine cancellation, not a
641
+ // platform failure. Kept out of `error`/`errorInfo`.
207
642
  this._setCancelled(true);
208
643
  }
209
644
  else {
210
- this._setError(e);
645
+ const message = String(e?.message ?? "Contact selection failed.");
646
+ this._setErrorInfo(WCS_CONTACTS_ERROR_CODE.SelectFailed, "execute", true, message);
647
+ this._setError(e ?? { message });
211
648
  }
212
649
  this._setLoading(false);
650
+ this._lane.finalize(ticket);
213
651
  return null;
214
652
  }
215
653
  }
@@ -311,6 +749,9 @@ class WcsContacts extends HTMLElement {
311
749
  get cancelled() {
312
750
  return this._core.cancelled;
313
751
  }
752
+ get errorInfo() {
753
+ return this._core.errorInfo;
754
+ }
314
755
  get connectedCallbackPromise() {
315
756
  return this._connectedCallbackPromise;
316
757
  }
@@ -341,5 +782,5 @@ function bootstrapContacts(userConfig) {
341
782
  registerComponents();
342
783
  }
343
784
 
344
- export { ContactsCore, WcsContacts, bootstrapContacts, getConfig };
785
+ export { ContactsCore, WCS_CONTACTS_ERROR_CODE, WcsContacts, bootstrapContacts, getConfig };
345
786
  //# sourceMappingURL=index.esm.js.map