@whooshbang/sdk 5.1.0 → 6.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 (67) hide show
  1. package/README.md +232 -83
  2. package/dist/cli/commands.js +2 -0
  3. package/dist/cli/commands.js.map +1 -1
  4. package/dist/client.d.ts +139 -1
  5. package/dist/client.js +448 -4
  6. package/dist/client.js.map +1 -1
  7. package/dist/index.d.ts +2 -2
  8. package/dist/index.js.map +1 -1
  9. package/dist/interactions.js +18 -3
  10. package/dist/interactions.js.map +1 -1
  11. package/dist/recipient/element.js +641 -14
  12. package/dist/recipient/element.js.map +1 -1
  13. package/dist/recipient/i18n/ar.js +77 -0
  14. package/dist/recipient/i18n/ar.js.map +1 -1
  15. package/dist/recipient/i18n/catalog.d.ts +75 -0
  16. package/dist/recipient/i18n/catalog.js.map +1 -1
  17. package/dist/recipient/i18n/de.js +82 -0
  18. package/dist/recipient/i18n/de.js.map +1 -1
  19. package/dist/recipient/i18n/en.js +82 -0
  20. package/dist/recipient/i18n/en.js.map +1 -1
  21. package/dist/recipient/i18n/es.js +82 -0
  22. package/dist/recipient/i18n/es.js.map +1 -1
  23. package/dist/recipient/i18n/fa.js +77 -0
  24. package/dist/recipient/i18n/fa.js.map +1 -1
  25. package/dist/recipient/i18n/fr.js +82 -0
  26. package/dist/recipient/i18n/fr.js.map +1 -1
  27. package/dist/recipient/i18n/hi.js +82 -0
  28. package/dist/recipient/i18n/hi.js.map +1 -1
  29. package/dist/recipient/i18n/id.js +82 -0
  30. package/dist/recipient/i18n/id.js.map +1 -1
  31. package/dist/recipient/i18n/it.js +82 -0
  32. package/dist/recipient/i18n/it.js.map +1 -1
  33. package/dist/recipient/i18n/nl.js +82 -0
  34. package/dist/recipient/i18n/nl.js.map +1 -1
  35. package/dist/recipient/i18n/pl.js +82 -0
  36. package/dist/recipient/i18n/pl.js.map +1 -1
  37. package/dist/recipient/i18n/pt-BR.js +82 -0
  38. package/dist/recipient/i18n/pt-BR.js.map +1 -1
  39. package/dist/recipient/i18n/ru.js +82 -0
  40. package/dist/recipient/i18n/ru.js.map +1 -1
  41. package/dist/recipient/i18n/tr.js +82 -0
  42. package/dist/recipient/i18n/tr.js.map +1 -1
  43. package/dist/recipient/i18n/uk.js +82 -0
  44. package/dist/recipient/i18n/uk.js.map +1 -1
  45. package/dist/recipient/i18n/zh-Hans.js +77 -0
  46. package/dist/recipient/i18n/zh-Hans.js.map +1 -1
  47. package/dist/recipient/order-section.d.ts +41 -0
  48. package/dist/recipient/order-section.js +547 -0
  49. package/dist/recipient/order-section.js.map +1 -0
  50. package/dist/recipient/order-view.d.ts +123 -0
  51. package/dist/recipient/order-view.js +304 -0
  52. package/dist/recipient/order-view.js.map +1 -0
  53. package/dist/recipient/platform-mark.d.ts +1 -0
  54. package/dist/recipient/platform-mark.js +57 -0
  55. package/dist/recipient/platform-mark.js.map +1 -0
  56. package/dist/recipient/quiet-section.d.ts +47 -0
  57. package/dist/recipient/quiet-section.js +490 -0
  58. package/dist/recipient/quiet-section.js.map +1 -0
  59. package/dist/recipient/quiet-view.d.ts +84 -0
  60. package/dist/recipient/quiet-view.js +250 -0
  61. package/dist/recipient/quiet-view.js.map +1 -0
  62. package/dist/recipient/view.d.ts +2 -0
  63. package/dist/recipient/view.js.map +1 -1
  64. package/dist/webhooks.d.ts +30 -1
  65. package/dist/webhooks.js +57 -4
  66. package/dist/webhooks.js.map +1 -1
  67. package/package.json +12 -2
@@ -3,7 +3,12 @@ import { WhooshBangRecipientSessionClient } from "../client.js";
3
3
  import { WhooshBangProblemError } from "../errors.js";
4
4
  import { recipientPanelCopy, resolveRecipientPanelCopy, } from "./i18n/catalog.js";
5
5
  import { createOpenerNonce, isExpectedHandoffCompletion, launchHandoff, WHOOSHBANG_HOSTED_ORIGIN, } from "./handoff.js";
6
- import { recipientView, } from "./view.js";
6
+ import { whooshbangMark } from "./platform-mark.js";
7
+ import { ORDER_SECTION_STYLES, orderPreviewText, renderOrderSection, } from "./order-section.js";
8
+ import { addStep, draftChanged, moveStep, refreshDraft, removeStep, saveRequest, setWait, startDraft, stepKey, } from "./order-view.js";
9
+ import { QUIET_SECTION_STYLES, renderQuietSection } from "./quiet-section.js";
10
+ import { addQuietWindow, browserTimeZone, QUIET_CHANNELS, quietChanged, quietDraftErrors, quietEdited, quietErrorControl, quietProblemErrors, quietSaveRequest, removeQuietWindow, setQuietWindow, startQuietDraft, toggleQuietDay, } from "./quiet-view.js";
11
+ import { channelLabel, recipientView, } from "./view.js";
7
12
  export const RECIPIENT_SETTINGS_ELEMENT = "whooshbang-recipient-settings";
8
13
  /**
9
14
  * How long the component keeps asking after a handoff opens.
@@ -125,12 +130,44 @@ a.fallback { color: inherit; }
125
130
  padding-top: .75rem;
126
131
  text-align: end;
127
132
  }
128
- .platform-brand a { color: inherit; opacity: .78; text-decoration: none; }
133
+ .platform-brand a {
134
+ align-items: center;
135
+ color: inherit;
136
+ display: inline-flex;
137
+ gap: .35rem;
138
+ opacity: .78;
139
+ text-decoration: none;
140
+ }
141
+ .platform-brand .platform-mark { flex: 0 0 auto; }
129
142
  .platform-brand a:hover { opacity: 1; text-decoration: underline; }
143
+ .tabs {
144
+ border-bottom: 1px solid color-mix(in srgb, currentColor 18%, transparent);
145
+ display: flex;
146
+ gap: .25rem;
147
+ margin: .25rem 0 1rem;
148
+ }
149
+ .tabs button.tab {
150
+ background: transparent;
151
+ border-bottom: 2px solid transparent;
152
+ border-radius: 0;
153
+ color: inherit;
154
+ flex: 1 1 auto;
155
+ margin-bottom: -1px;
156
+ min-height: 2.75rem;
157
+ opacity: .75;
158
+ padding: .5rem .5rem;
159
+ }
160
+ .tabs button.tab[aria-selected="true"] {
161
+ border-bottom-color: var(--whooshbang-accent, ${RECIPIENT_BRAND_DEFAULT_ACCENT_COLOR});
162
+ font-weight: 600;
163
+ opacity: 1;
164
+ }
165
+ [role="tabpanel"][hidden] { display: none; }
130
166
  @media (max-width: 30rem) {
131
167
  li { align-items: flex-start; flex-direction: column; }
132
168
  }
133
- `;
169
+ ${ORDER_SECTION_STYLES}
170
+ ${QUIET_SECTION_STYLES}`;
134
171
  function statusText(channel, copy) {
135
172
  if (channel.paused) {
136
173
  return copy.paused;
@@ -185,10 +222,40 @@ export class WhooshBangRecipientSettingsElement extends HTMLElement {
185
222
  #sessionInput = null;
186
223
  #view;
187
224
  #visibilityListener = null;
225
+ /**
226
+ * The recipient's own escalation order, by tier (FXO-3672). Null until it
227
+ * loads, or when the service does not offer it, and then the section is
228
+ * simply absent.
229
+ */
230
+ #orders = null;
231
+ #orderLevel = "critical";
232
+ /** An unsaved edit; it belongs to one tier and survives refreshes. */
233
+ #orderDraft = null;
234
+ /** The section's own status line, kept for the element's life like `#live`. */
235
+ #orderStatus;
236
+ /** Where focus goes after an order action that removes its own control. */
237
+ #orderFocus = null;
238
+ /**
239
+ * The recipient's quiet hours (FXO-2103). Null until they load, or when the
240
+ * service does not offer them, and then the tab is simply absent.
241
+ */
242
+ #quiet = null;
243
+ /** An unsaved edit; it survives refreshes. */
244
+ #quietDraft = null;
245
+ /** What the last save attempt refused, by field. */
246
+ #quietErrors = {};
247
+ /** The tab's own status line, kept for the element's life like `#live`. */
248
+ #quietStatus;
249
+ /** The zone times are shown in when the service knows none. */
250
+ #browserZone = browserTimeZone();
251
+ /** The panel tab on show; kept across refreshes and polls. */
252
+ #tab = "channels";
188
253
  constructor() {
189
254
  super();
190
255
  this.#root = this.attachShadow({ mode: "open" });
191
256
  this.#live = document.createElement("p");
257
+ this.#orderStatus = document.createElement("p");
258
+ this.#quietStatus = document.createElement("p");
192
259
  this.#view = recipientView({ session: null, state: "loading" });
193
260
  }
194
261
  get session() {
@@ -210,6 +277,13 @@ export class WhooshBangRecipientSettingsElement extends HTMLElement {
210
277
  this.#categories = [];
211
278
  this.#armedUnsubscribe = null;
212
279
  this.#connecting = null;
280
+ this.#orders = null;
281
+ this.#orderDraft = null;
282
+ this.#orderStatus.textContent = "";
283
+ this.#quiet = null;
284
+ this.#quietDraft = null;
285
+ this.#quietErrors = {};
286
+ this.#quietStatus.textContent = "";
213
287
  if (!value) {
214
288
  this.#releaseLogo();
215
289
  this.#client = null;
@@ -437,22 +511,37 @@ export class WhooshBangRecipientSettingsElement extends HTMLElement {
437
511
  }
438
512
  const generation = this.#generation;
439
513
  try {
440
- const [session, categoryPreferences] = await Promise.all([
514
+ const [session, categoryPreferences, orders, quiet] = await Promise.all([
441
515
  client.getSession({ timeoutMs: REQUEST_TIMEOUT_MS }),
442
516
  client
443
517
  .getCategoryPreferences({ timeoutMs: REQUEST_TIMEOUT_MS })
444
518
  .catch(() => null),
519
+ // A service without personal orders leaves the section out.
520
+ client
521
+ .getEscalationOrders({ timeoutMs: REQUEST_TIMEOUT_MS })
522
+ .catch(() => null),
523
+ // A service without quiet hours leaves the tab out.
524
+ client
525
+ .getQuietHours({ timeoutMs: REQUEST_TIMEOUT_MS })
526
+ .catch(() => null),
445
527
  ]);
446
528
  if (generation !== this.#generation) {
447
529
  return;
448
530
  }
449
531
  this.#session = session;
532
+ // A failed read keeps what was shown; the next refresh tries again.
533
+ this.#orders = orders?.items ?? this.#orders;
534
+ this.#quiet = quiet ?? this.#quiet;
535
+ const refreshed = this.#currentOrder();
536
+ if (this.#orderDraft && refreshed?.level === this.#orderDraft.level)
537
+ this.#orderDraft = refreshDraft(this.#orderDraft, refreshed);
450
538
  this.#categories = (categoryPreferences?.items ?? []).map((category) => ({
451
539
  key: category.key,
452
540
  name: category.name,
453
541
  ...(category.description ? { description: category.description } : {}),
454
542
  enabled: category.can_turn_off ? category.enabled : true,
455
543
  canTurnOff: category.can_turn_off,
544
+ level: category.level,
456
545
  }));
457
546
  this.#render(recipientView({
458
547
  categories: this.#categories,
@@ -659,6 +748,13 @@ export class WhooshBangRecipientSettingsElement extends HTMLElement {
659
748
  timeoutMs: REQUEST_TIMEOUT_MS,
660
749
  });
661
750
  this.#session = session;
751
+ // Whether the quiet-hours channel can be used follows its binding: a
752
+ // pause or resume changes the warning the Quiet hours tab shows.
753
+ const quiet = await client
754
+ .getQuietHours({ timeoutMs: REQUEST_TIMEOUT_MS })
755
+ .catch(() => null);
756
+ if (quiet !== null)
757
+ this.#quiet = quiet;
662
758
  this.#notice = null;
663
759
  this.#render(recipientView({
664
760
  categories: this.#categories,
@@ -722,6 +818,494 @@ export class WhooshBangRecipientSettingsElement extends HTMLElement {
722
818
  this.#renderFailure(error);
723
819
  }
724
820
  }
821
+ #currentOrder() {
822
+ return this.#orders?.find((order) => order.level === this.#orderLevel);
823
+ }
824
+ #draftFor(order) {
825
+ return this.#orderDraft?.level === order.level
826
+ ? this.#orderDraft
827
+ : startDraft(order);
828
+ }
829
+ #pickOrderLevel(level) {
830
+ // A tier change drops an unsaved edit, exactly as the canvas does.
831
+ this.#orderLevel = level;
832
+ this.#orderDraft = null;
833
+ this.#orderStatus.textContent = "";
834
+ this.#render(this.#view);
835
+ }
836
+ /**
837
+ * Applies one edit. `focus` names the control that should hold focus
838
+ * afterwards, for edits that move or remove the one that was pressed.
839
+ */
840
+ #editOrder(change, options = {}) {
841
+ const order = this.#currentOrder();
842
+ if (!order || this.#pending)
843
+ return;
844
+ const before = draftChanged(this.#orderDraft, order);
845
+ this.#orderDraft = change(this.#draftFor(order));
846
+ this.#orderStatus.textContent = "";
847
+ if (options.focus !== undefined)
848
+ this.#orderFocus = options.focus;
849
+ if (options.render !== false ||
850
+ before !== draftChanged(this.#orderDraft, order)) {
851
+ this.#render(this.#view);
852
+ return;
853
+ }
854
+ // Typing a wait updates the preview in place, keeping the box it is in.
855
+ const preview = this.#root.querySelector(".order .preview-text");
856
+ if (preview)
857
+ preview.textContent = orderPreviewText({
858
+ channelName: (channel) => channelLabel(channel, this.#view.locale),
859
+ copy: this.#copy,
860
+ draft: this.#orderDraft,
861
+ order,
862
+ });
863
+ }
864
+ #replaceOrder(order) {
865
+ this.#orders = (this.#orders ?? []).map((current) => current.level === order.level ? order : current);
866
+ }
867
+ async #reloadOrders() {
868
+ const client = this.#client;
869
+ if (!client)
870
+ return;
871
+ const generation = this.#generation;
872
+ try {
873
+ const orders = await client.getEscalationOrders({
874
+ timeoutMs: REQUEST_TIMEOUT_MS,
875
+ });
876
+ if (generation === this.#generation)
877
+ this.#orders = orders.items;
878
+ }
879
+ catch {
880
+ // The section keeps what it showed; the status line says what failed.
881
+ }
882
+ }
883
+ async #orderFailure(error) {
884
+ const code = error instanceof WhooshBangProblemError ? error.problem.code : null;
885
+ if (code === "recipient_capability_invalid" ||
886
+ code === "resource_expired") {
887
+ this.#renderFailure(error);
888
+ return;
889
+ }
890
+ this.#emit("error", { ...(code ? { code } : {}), recoverable: true });
891
+ if (code === "escalation_order_conflict" ||
892
+ code === "escalation_order_step_ineligible") {
893
+ // The tier changed under the edit: show what is true now, and keep
894
+ // nothing that was made against the old version.
895
+ const generation = this.#generation;
896
+ await this.#reloadOrders();
897
+ if (generation !== this.#generation)
898
+ return;
899
+ this.#orderDraft = null;
900
+ this.#orderStatus.textContent =
901
+ code === "escalation_order_conflict"
902
+ ? this.#copy.orderConflict
903
+ : this.#copy.orderSaveFailed;
904
+ return;
905
+ }
906
+ // Anything else keeps the edit, so trying again costs nothing.
907
+ this.#orderStatus.textContent = this.#copy.orderSaveFailed;
908
+ }
909
+ async #saveOrder() {
910
+ const client = this.#client;
911
+ const order = this.#currentOrder();
912
+ const draft = this.#orderDraft;
913
+ if (!client || !order || !draft || draft.level !== order.level)
914
+ return;
915
+ if (this.#pending)
916
+ return;
917
+ await this.#writeOrder(() => client.saveEscalationOrder(order.level, saveRequest(draft, order), {
918
+ timeoutMs: REQUEST_TIMEOUT_MS,
919
+ }), "order:reset");
920
+ }
921
+ async #resetOrder() {
922
+ const client = this.#client;
923
+ const order = this.#currentOrder();
924
+ if (!client || !order || this.#pending)
925
+ return;
926
+ await this.#writeOrder(() => client.resetEscalationOrder(order.level, {
927
+ timeoutMs: REQUEST_TIMEOUT_MS,
928
+ }), `order:level:${order.level}`);
929
+ }
930
+ async #writeOrder(write, focusAfter) {
931
+ const generation = this.#generation;
932
+ this.#pending = true;
933
+ this.#orderFocus = focusAfter;
934
+ this.#render(this.#view);
935
+ try {
936
+ const written = await write();
937
+ if (generation !== this.#generation)
938
+ return;
939
+ this.#replaceOrder(written);
940
+ this.#orderDraft = null;
941
+ this.#orderStatus.textContent = this.#copy.orderSaved;
942
+ this.#emit("escalation-order-changed", {
943
+ level: written.level,
944
+ source: written.source,
945
+ });
946
+ }
947
+ catch (error) {
948
+ if (generation !== this.#generation)
949
+ return;
950
+ await this.#orderFailure(error);
951
+ }
952
+ finally {
953
+ // Always released: a session replaced mid-save must not leave every
954
+ // control in the new one refusing to act.
955
+ this.#pending = false;
956
+ if (generation === this.#generation) {
957
+ if (this.#view.state !== "expired") {
958
+ this.#orderFocus ??= focusAfter;
959
+ this.#render(this.#view);
960
+ }
961
+ }
962
+ }
963
+ }
964
+ #orderSection(view) {
965
+ const order = this.#currentOrder();
966
+ if (!order ||
967
+ (view.state !== "ready" && view.state !== "handoff") ||
968
+ view.channels.length === 0)
969
+ return null;
970
+ const draft = this.#draftFor(order);
971
+ const copy = this.#copy;
972
+ this.#orderStatus.className = "order-status";
973
+ this.#orderStatus.setAttribute("role", "status");
974
+ this.#orderStatus.setAttribute("aria-live", "polite");
975
+ return renderOrderSection({
976
+ canReconnect: (channel) => view.channels.some((candidate) => candidate.channel === channel &&
977
+ !candidate.connected &&
978
+ !candidate.paused) && this.#connecting === null,
979
+ changed: draftChanged(this.#orderDraft, order),
980
+ channelName: (channel) => channelLabel(channel, view.locale),
981
+ copy,
982
+ disabled: this.#pending,
983
+ draft,
984
+ locale: view.locale,
985
+ on: {
986
+ add: (candidate) => {
987
+ this.#editOrder((current) => addStep(current, candidate), {
988
+ focus: `order:remove:${candidate.bindingId}`,
989
+ });
990
+ },
991
+ discard: () => {
992
+ this.#orderDraft = null;
993
+ this.#orderFocus = `order:level:${order.level}`;
994
+ this.#render(this.#view);
995
+ },
996
+ move: (from, to) => {
997
+ const moving = draft.steps[from];
998
+ if (!moving || to < 0 || to >= draft.steps.length)
999
+ return;
1000
+ const pressed = to < from ? "up" : "down";
1001
+ this.#editOrder((current) => moveStep(current, from, to), {
1002
+ focus: `order:${pressed}:${stepKey(moving, to)}`,
1003
+ });
1004
+ },
1005
+ pickLevel: (level) => {
1006
+ this.#pickOrderLevel(level);
1007
+ },
1008
+ reconnect: (channel) => {
1009
+ void this.#connect(channel);
1010
+ },
1011
+ remove: (index) => {
1012
+ const removed = draft.steps[index];
1013
+ const next = draft.steps[index + 1] ?? draft.steps[index - 1];
1014
+ this.#editOrder((current) => removeStep(current, index), {
1015
+ focus: removed?.bindingId !== undefined && !removed.unavailable
1016
+ ? `order:add:${removed.bindingId}`
1017
+ : next
1018
+ ? `order:remove:${stepKey(next, index < draft.steps.length - 1 ? index : index - 1)}`
1019
+ : `order:level:${order.level}`,
1020
+ });
1021
+ },
1022
+ reset: () => {
1023
+ void this.#resetOrder();
1024
+ },
1025
+ save: () => {
1026
+ void this.#saveOrder();
1027
+ },
1028
+ setWait: (index, typed, commit) => {
1029
+ this.#editOrder((current) => setWait(current, index, typed), {
1030
+ render: commit,
1031
+ });
1032
+ },
1033
+ },
1034
+ order,
1035
+ status: this.#orderStatus,
1036
+ usedFor: view.categories
1037
+ .filter((category) => category.level === order.level && category.enabled)
1038
+ .map((category) => category.name),
1039
+ });
1040
+ }
1041
+ #quietShown(quiet) {
1042
+ return this.#quietDraft ?? startQuietDraft(quiet, this.#browserZone);
1043
+ }
1044
+ /**
1045
+ * Applies one quiet-hours edit. Typing a time does not rebuild the tab, so
1046
+ * the box keeps its caret; only Save and Discard learn whether there is
1047
+ * now something to save.
1048
+ */
1049
+ #editQuiet(change, options = {}) {
1050
+ const quiet = this.#quiet;
1051
+ if (!quiet || this.#pending)
1052
+ return;
1053
+ this.#quietDraft = change(this.#quietShown(quiet));
1054
+ this.#quietStatus.textContent = "";
1055
+ if (options.focus !== undefined)
1056
+ this.#orderFocus = options.focus;
1057
+ if (options.render !== false) {
1058
+ this.#render(this.#view);
1059
+ return;
1060
+ }
1061
+ const save = this.#root.querySelector('[data-action="quiet:save"]');
1062
+ if (save)
1063
+ save.disabled = !quietChanged(this.#quietDraft, quiet, this.#browserZone);
1064
+ const discard = this.#root.querySelector('[data-action="quiet:discard"]');
1065
+ if (discard)
1066
+ discard.disabled = !quietEdited(this.#quietDraft, quiet, this.#browserZone);
1067
+ }
1068
+ /** Shows what a save refused, field by field, and moves focus to the first. */
1069
+ #refuseQuiet(errors, draft) {
1070
+ this.#quietErrors = errors;
1071
+ this.#quietStatus.textContent = this.#copy.quietFixFields;
1072
+ const [first] = Object.keys(errors);
1073
+ const control = first === undefined ? null : quietErrorControl(first, draft);
1074
+ if (control !== null)
1075
+ this.#orderFocus = control;
1076
+ }
1077
+ async #saveQuiet() {
1078
+ const client = this.#client;
1079
+ const quiet = this.#quiet;
1080
+ if (!client || !quiet || this.#pending)
1081
+ return;
1082
+ const draft = this.#quietShown(quiet);
1083
+ const local = quietDraftErrors(draft);
1084
+ if (Object.keys(local).length > 0) {
1085
+ this.#refuseQuiet(local, draft);
1086
+ this.#render(this.#view);
1087
+ return;
1088
+ }
1089
+ await this.#writeQuiet(() => client.saveQuietHours(quietSaveRequest(draft), {
1090
+ timeoutMs: REQUEST_TIMEOUT_MS,
1091
+ }), this.#copy.quietSaved, draft);
1092
+ }
1093
+ async #turnOffQuiet() {
1094
+ const client = this.#client;
1095
+ if (!client || !this.#quiet || this.#pending)
1096
+ return;
1097
+ await this.#writeQuiet(() => client.resetQuietHours({ timeoutMs: REQUEST_TIMEOUT_MS }), this.#copy.quietTurnedOff, null);
1098
+ }
1099
+ async #writeQuiet(write, done, draft) {
1100
+ const generation = this.#generation;
1101
+ this.#pending = true;
1102
+ this.#render(this.#view);
1103
+ this.#orderFocus = null;
1104
+ try {
1105
+ const written = await write();
1106
+ if (generation !== this.#generation)
1107
+ return;
1108
+ this.#quiet = written;
1109
+ this.#quietDraft = null;
1110
+ this.#quietErrors = {};
1111
+ this.#quietStatus.textContent = done;
1112
+ // Save has nothing left to do; the next thing is turning it off, or,
1113
+ // once off, turning it on again.
1114
+ this.#orderFocus = written.configured ? "quiet:turn-off" : "quiet:save";
1115
+ this.#emit("quiet-hours-changed", { configured: written.configured });
1116
+ }
1117
+ catch (error) {
1118
+ if (generation !== this.#generation)
1119
+ return;
1120
+ const problem = error instanceof WhooshBangProblemError ? error.problem : null;
1121
+ if (problem?.code === "recipient_capability_invalid" ||
1122
+ problem?.code === "resource_expired") {
1123
+ this.#renderFailure(error);
1124
+ return;
1125
+ }
1126
+ this.#emit("error", {
1127
+ ...(problem ? { code: problem.code } : {}),
1128
+ recoverable: true,
1129
+ });
1130
+ const refused = problem?.code === "request_invalid" ? quietProblemErrors(problem) : {};
1131
+ // The edit is kept either way, so trying again costs nothing.
1132
+ if (draft !== null && Object.keys(refused).length > 0)
1133
+ this.#refuseQuiet(refused, draft);
1134
+ else
1135
+ this.#quietStatus.textContent = this.#copy.quietSaveFailed;
1136
+ }
1137
+ finally {
1138
+ this.#pending = false;
1139
+ if (generation === this.#generation && this.#view.state !== "expired") {
1140
+ this.#orderFocus ??= "quiet:save";
1141
+ this.#render(this.#view);
1142
+ }
1143
+ }
1144
+ }
1145
+ #quietSection(view) {
1146
+ const quiet = this.#quiet;
1147
+ if (!quiet || (view.state !== "ready" && view.state !== "handoff"))
1148
+ return null;
1149
+ const draft = this.#quietShown(quiet);
1150
+ const offered = new Set(view.channels.map((channel) => channel.channel));
1151
+ const channels = QUIET_CHANNELS.filter((channel) => offered.has(channel));
1152
+ this.#quietStatus.className = "quiet-status";
1153
+ this.#quietStatus.setAttribute("role", "status");
1154
+ this.#quietStatus.setAttribute("aria-live", "polite");
1155
+ const clear = (field) => {
1156
+ if (this.#quietErrors[field] === undefined)
1157
+ return;
1158
+ this.#quietErrors = Object.fromEntries(Object.entries(this.#quietErrors).filter(([key]) => key !== field));
1159
+ };
1160
+ return renderQuietSection({
1161
+ changed: quietChanged(this.#quietDraft, quiet, this.#browserZone),
1162
+ channelName: (channel) => channelLabel(channel, view.locale),
1163
+ channels: channels.length > 0 ? channels : QUIET_CHANNELS,
1164
+ copy: this.#copy,
1165
+ disabled: this.#pending,
1166
+ draft,
1167
+ edited: quietEdited(this.#quietDraft, quiet, this.#browserZone),
1168
+ errors: this.#quietErrors,
1169
+ locale: view.locale,
1170
+ on: {
1171
+ addWindow: () => {
1172
+ clear("/windows");
1173
+ this.#editQuiet(addQuietWindow, {
1174
+ focus: `quiet:remove:${String(draft.windows.length)}`,
1175
+ });
1176
+ },
1177
+ discard: () => {
1178
+ this.#quietDraft = null;
1179
+ this.#quietErrors = {};
1180
+ this.#quietStatus.textContent = "";
1181
+ this.#orderFocus = "quiet:save";
1182
+ this.#render(this.#view);
1183
+ },
1184
+ removeWindow: (index) => {
1185
+ // Errors are kept by position, which a removal shifts.
1186
+ this.#quietErrors = {};
1187
+ const remaining = draft.windows.length - 1;
1188
+ this.#editQuiet((current) => removeQuietWindow(current, index), {
1189
+ focus: remaining === 0
1190
+ ? "quiet:add"
1191
+ : `quiet:remove:${String(Math.min(index, remaining - 1))}`,
1192
+ });
1193
+ },
1194
+ save: () => {
1195
+ void this.#saveQuiet();
1196
+ },
1197
+ setBehaviour: (channel, behaviour) => {
1198
+ clear(`/channels/${channel}`);
1199
+ this.#editQuiet((current) => ({
1200
+ ...current,
1201
+ channels: { ...current.channels, [channel]: behaviour },
1202
+ }));
1203
+ },
1204
+ setCritical: (on) => {
1205
+ this.#editQuiet((current) => ({
1206
+ ...current,
1207
+ criticalBreakthrough: on,
1208
+ }));
1209
+ },
1210
+ setQuietChannel: (bindingId) => {
1211
+ clear("/quiet_channel_binding_id");
1212
+ this.#editQuiet((current) => ({
1213
+ ...current,
1214
+ quietChannelBindingId: bindingId,
1215
+ }));
1216
+ },
1217
+ setTime: (index, edge, value) => {
1218
+ this.#editQuiet((current) => setQuietWindow(current, index, { [edge]: value }), { render: false });
1219
+ },
1220
+ setTimeZone: (zone) => {
1221
+ clear("/time_zone");
1222
+ this.#editQuiet((current) => ({ ...current, timeZone: zone }));
1223
+ },
1224
+ setWhenWithheld: (value) => {
1225
+ clear("/quiet_channel_binding_id");
1226
+ this.#editQuiet((current) => ({ ...current, whenWithheld: value }));
1227
+ },
1228
+ toggleDay: (index, day) => {
1229
+ clear(`/windows/${String(index)}/days`);
1230
+ this.#editQuiet((current) => toggleQuietDay(current, index, day));
1231
+ },
1232
+ turnOff: () => {
1233
+ void this.#turnOffQuiet();
1234
+ },
1235
+ },
1236
+ quiet,
1237
+ status: this.#quietStatus,
1238
+ });
1239
+ }
1240
+ /**
1241
+ * The panel's parts as tabs: channels, the escalation order, quiet hours
1242
+ * and the kinds of message. Every part is in the tree; only the chosen one is shown, so a
1243
+ * refresh or poll never moves the recipient off the tab they are on.
1244
+ */
1245
+ #appendParts(surface, parts, copy) {
1246
+ const [only] = parts;
1247
+ if (parts.length === 1 && only) {
1248
+ surface.append(only.content);
1249
+ return;
1250
+ }
1251
+ const selected = parts.some((part) => part.key === this.#tab)
1252
+ ? this.#tab
1253
+ : "channels";
1254
+ const tablist = document.createElement("div");
1255
+ tablist.className = "tabs";
1256
+ tablist.setAttribute("role", "tablist");
1257
+ tablist.setAttribute("aria-label", copy.heading);
1258
+ const choose = (key, focus) => {
1259
+ this.#tab = key;
1260
+ if (focus)
1261
+ this.#orderFocus = `tab:${key}`;
1262
+ this.#render(this.#view);
1263
+ };
1264
+ parts.forEach((part, index) => {
1265
+ const chosen = part.key === selected;
1266
+ const tab = document.createElement("button");
1267
+ tab.type = "button";
1268
+ tab.className = "tab";
1269
+ tab.id = `tab-${part.key}`;
1270
+ tab.dataset["action"] = `tab:${part.key}`;
1271
+ tab.setAttribute("role", "tab");
1272
+ tab.setAttribute("aria-controls", `panel-${part.key}`);
1273
+ tab.setAttribute("aria-selected", String(chosen));
1274
+ tab.tabIndex = chosen ? 0 : -1;
1275
+ tab.textContent = part.label;
1276
+ tab.addEventListener("click", () => {
1277
+ choose(part.key, false);
1278
+ });
1279
+ // Arrow keys move between tabs, Home and End to the ends.
1280
+ tab.addEventListener("keydown", (event) => {
1281
+ const last = parts.length - 1;
1282
+ const target = event.key === "ArrowRight"
1283
+ ? index === last
1284
+ ? 0
1285
+ : index + 1
1286
+ : event.key === "ArrowLeft"
1287
+ ? index === 0
1288
+ ? last
1289
+ : index - 1
1290
+ : event.key === "Home"
1291
+ ? 0
1292
+ : event.key === "End"
1293
+ ? last
1294
+ : null;
1295
+ const next = target === null ? undefined : parts[target];
1296
+ if (!next)
1297
+ return;
1298
+ event.preventDefault();
1299
+ choose(next.key, true);
1300
+ });
1301
+ tablist.append(tab);
1302
+ part.content.id = `panel-${part.key}`;
1303
+ part.content.setAttribute("role", "tabpanel");
1304
+ part.content.setAttribute("aria-labelledby", `tab-${part.key}`);
1305
+ part.content.hidden = !chosen;
1306
+ });
1307
+ surface.append(tablist, ...parts.map((part) => part.content));
1308
+ }
725
1309
  /**
726
1310
  * Rebuilds the shadow tree.
727
1311
  *
@@ -889,16 +1473,36 @@ export class WhooshBangRecipientSettingsElement extends HTMLElement {
889
1473
  item.append(actions);
890
1474
  list.append(item);
891
1475
  }
892
- surface.append(list);
1476
+ const channelsPanel = document.createElement("div");
1477
+ channelsPanel.append(list);
1478
+ const parts = [
1479
+ { content: channelsPanel, key: "channels", label: copy.tabChannels },
1480
+ ];
1481
+ const orderSection = this.#orderSection(view);
1482
+ if (orderSection)
1483
+ parts.push({
1484
+ content: orderSection,
1485
+ key: "order",
1486
+ label: copy.orderHeading,
1487
+ });
1488
+ const quietSection = this.#quietSection(view);
1489
+ if (quietSection)
1490
+ parts.push({
1491
+ content: quietSection,
1492
+ key: "quiet",
1493
+ label: copy.quietHeading,
1494
+ });
893
1495
  if (view.categories.length > 0) {
894
- const categoryHeading = document.createElement("h3");
895
- categoryHeading.className = "categories-heading";
896
- categoryHeading.textContent = copy.categoriesHeading;
897
- surface.append(categoryHeading);
1496
+ const categoriesPanel = document.createElement("div");
1497
+ parts.push({
1498
+ content: categoriesPanel,
1499
+ key: "categories",
1500
+ label: copy.categoriesHeading,
1501
+ });
898
1502
  const categoryBody = document.createElement("p");
899
1503
  categoryBody.className = "status";
900
1504
  categoryBody.textContent = copy.categoriesBody;
901
- surface.append(categoryBody);
1505
+ categoriesPanel.append(categoryBody);
902
1506
  const categories = document.createElement("ul");
903
1507
  for (const category of view.categories) {
904
1508
  const item = document.createElement("li");
@@ -941,8 +1545,9 @@ export class WhooshBangRecipientSettingsElement extends HTMLElement {
941
1545
  item.append(control);
942
1546
  categories.append(item);
943
1547
  }
944
- surface.append(categories);
1548
+ categoriesPanel.append(categories);
945
1549
  }
1550
+ this.#appendParts(surface, parts, copy);
946
1551
  // Customer identity owns the panel heading; this quiet attribution names
947
1552
  // the product operating the component without replacing or competing with
948
1553
  // the customer's configured name and logo.
@@ -955,12 +1560,34 @@ export class WhooshBangRecipientSettingsElement extends HTMLElement {
955
1560
  platformLink.href = "https://whooshbang.com";
956
1561
  platformLink.rel = "noreferrer";
957
1562
  platformLink.target = "_blank";
958
- platformLink.textContent = "Powered by WhooshBang";
1563
+ platformLink.append(whooshbangMark(), "Powered by WhooshBang");
959
1564
  platformBrand.append(platformLink);
960
1565
  surface.append(platformBrand);
961
1566
  root.append(surface);
962
- if (focusedControl) {
963
- const restored = root.querySelector(`[data-action="${focusedControl}"], [data-focus-key="${focusedControl}"]`);
1567
+ const wanted = this.#orderFocus ?? focusedControl;
1568
+ this.#orderFocus = null;
1569
+ if (wanted) {
1570
+ const find = (key) => root.querySelector(`[data-action="${key}"], [data-focus-key="${key}"]`);
1571
+ let restored = find(wanted);
1572
+ // An arrow that just reached the end of the list is disabled; its
1573
+ // partner is the natural next press. Any other vanished order control
1574
+ // hands focus to the tier the recipient is editing.
1575
+ if (wanted.startsWith("order:") && (!restored || restored.disabled)) {
1576
+ const partner = wanted.startsWith("order:up:")
1577
+ ? wanted.replace("order:up:", "order:down:")
1578
+ : wanted.startsWith("order:down:")
1579
+ ? wanted.replace("order:down:", "order:up:")
1580
+ : null;
1581
+ const alternate = partner ? find(partner) : null;
1582
+ restored =
1583
+ alternate && !alternate.disabled
1584
+ ? alternate
1585
+ : find(`order:level:${this.#orderLevel}`);
1586
+ }
1587
+ // A quiet-hours control that went away, or cannot take focus, hands
1588
+ // focus to the tab.
1589
+ if (wanted.startsWith("quiet:") && (!restored || restored.disabled))
1590
+ restored = find("tab:quiet");
964
1591
  restored?.focus();
965
1592
  }
966
1593
  else if (fallbackUrl) {