@lightworkai.official/debug-capture 0.14.2 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ export interface RingCall {
2
+ id: string;
3
+ ticketKey: string;
4
+ ticketTitle: string;
5
+ }
6
+ export interface RingHandlers {
7
+ /** Runs INSIDE the press: a tab can be opened only there, before anything is awaited. */
8
+ open: () => void;
9
+ decline: () => void;
10
+ }
11
+ /** "เรื่อง CALL-12 · title" — the line under the card's title. Pure, for the tests. */
12
+ export declare function ringAbout(template: string, call: RingCall): string;
13
+ /** Show the ring for this call — once; the same call again changes nothing. */
14
+ export declare function showRing(call: RingCall, handlers: RingHandlers): void;
15
+ /** Opened: the card says where to answer, and stops ringing. `blockedUrl` — the browser refused the tab: a link, which is a fresh press. */
16
+ export declare function ringOpened(blockedUrl?: string): void;
17
+ /** The call is no longer ringing for this person — answered, declined, missed or ended. */
18
+ export declare function hideRing(id?: string): void;
19
+ export declare function ringShownFor(): string | null;
@@ -66,14 +66,23 @@ export interface Strings {
66
66
  handoffOpen: string;
67
67
  chooseTitle: string;
68
68
  kindProblem: string;
69
- kindProblemHint: string;
70
69
  kindRequest: string;
71
- kindRequestHint: string;
70
+ askInstead: string;
71
+ askInsteadAction: string;
72
+ problemInstead: string;
73
+ problemInsteadAction: string;
74
+ /** The same, from a request that began with no screen: the share will be asked for. */
75
+ problemInsteadShare: string;
72
76
  /** On the cards before anything is asked of the browser: what each will do. */
73
77
  chooseProblemHint: string;
74
78
  chooseRequestHint: string;
75
79
  requestTitle: string;
76
80
  requestTypeLabel: string;
81
+ /** Over a long list of types: the search, and what it says when nothing matches. */
82
+ requestTypeSearch: string;
83
+ requestTypeNone: string;
84
+ /** Carries `{n}`. */
85
+ requestTypeCount: string;
77
86
  /** Carries `{time}`. */
78
87
  requestTarget: string;
79
88
  requestSummaryLabel: string;
@@ -94,5 +103,15 @@ export interface Strings {
94
103
  /** Carry `{n}`. */
95
104
  days: string;
96
105
  hours: string;
106
+ /** A call from the support team (ui/ring). `ringAbout` carries `{key}` and `{title}`. */
107
+ ringTitle: string;
108
+ ringAbout: string;
109
+ ringHow: string;
110
+ ringOpen: string;
111
+ ringDecline: string;
112
+ ringOpened: string;
113
+ ringBlocked: string;
114
+ ringClose: string;
115
+ ringTab: string;
97
116
  }
98
117
  export declare function strings(locale: "th" | "en" | undefined): Strings;
@@ -10,4 +10,4 @@
10
10
  * Everything is explicit for the same reason: the host's `body` font and colours
11
11
  * do not inherit past the boundary, so stating them is not belt-and-braces.
12
12
  */
13
- export declare const CSS = "\n:host { all: initial; }\n* { box-sizing: border-box; }\n\n.backdrop {\n position: fixed; inset: 0; z-index: 2147483000;\n display: flex; align-items: center; justify-content: center;\n padding: 24px; background: rgba(15, 23, 42, 0.55);\n font-family: system-ui, -apple-system, \"Segoe UI\", Roboto, \"Noto Sans Thai\", sans-serif;\n color: #0f172a;\n}\n.panel {\n display: flex; flex-direction: column; gap: 16px;\n width: min(820px, 100%); max-height: 90vh; overflow-y: auto;\n /* No bottom padding: the footer supplies its own and sits flush, so it can\n stick to the panel's edge without a gap under it. */\n padding: 24px 24px 0; border-radius: 16px; background: #fff;\n box-shadow: 0 24px 60px rgba(15, 23, 42, 0.28);\n}\n.head { display: flex; align-items: flex-start; gap: 12px; }\nh2 { margin: 0; font-size: 20px; font-weight: 700; flex: 1; }\nlabel { display: block; font-size: 14px; font-weight: 600; margin-bottom: 6px; }\n.req { color: #dc2626; }\n.hint { font-weight: 400; color: #64748b; }\ninput[type=\"text\"], textarea, select {\n width: 100%; padding: 10px 12px; font: inherit; font-size: 14px;\n border: 1px solid #cbd5e1; border-radius: 10px; background: #fff; color: inherit;\n}\ninput[type=\"text\"]:focus, textarea:focus, select:focus {\n outline: none; border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18);\n}\ntextarea { min-height: 88px; resize: vertical; }\n\n.meta {\n padding: 12px 14px; border-radius: 10px; background: #f8fafc;\n font-size: 13px; line-height: 1.7; color: #475569; word-break: break-all;\n}\n.meta b { color: #0f172a; font-weight: 600; }\n\n.tools { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }\n.sep { width: 1px; align-self: stretch; background: #e2e8f0; margin: 0 4px; }\nbutton {\n display: inline-flex; align-items: center; gap: 6px; cursor: pointer;\n padding: 8px 14px; font: inherit; font-size: 14px; font-weight: 500;\n border: 1px solid #cbd5e1; border-radius: 10px; background: #fff; color: inherit;\n}\nbutton:hover:not(:disabled) { background: #f1f5f9; }\nbutton:disabled { opacity: 0.5; cursor: not-allowed; }\nbutton[aria-pressed=\"true\"] { border-color: #2563eb; background: #eff6ff; color: #1d4ed8; }\nbutton.primary { background: #2563eb; border-color: #2563eb; color: #fff; }\nbutton.primary:hover:not(:disabled) { background: #1d4ed8; }\nbutton.icon { padding: 6px 10px; border-color: transparent; font-size: 18px; line-height: 1; }\n\n/* The on-image text editor. Absolutely placed over the canvas and matched to\n the shape it becomes \u2014 same weight, same red, same white plate \u2014 so what you\n type is what gets drawn, at the position you clicked. Sized in JS, because\n the font size tracks the image's own scale. */\n.text-edit {\n position: absolute; z-index: 2; margin: 0; padding: 2px 4px;\n font-family: system-ui, sans-serif; font-weight: 600; line-height: 1.25;\n color: #e11d48; background: rgba(255, 255, 255, 0.92);\n border: 1px dashed #e11d48; border-radius: 4px; outline: none;\n box-shadow: 0 2px 10px rgba(15, 23, 42, 0.18);\n}\n.text-edit::placeholder { color: rgba(225, 29, 72, 0.45); }\n\ncanvas { width: 100%; height: auto; display: block; border: 1px solid #e2e8f0; border-radius: 10px; touch-action: none; }\n.shot { position: relative; }\n.status { font-size: 13px; color: #64748b; }\n.status[data-tone=\"error\"] { color: #dc2626; }\n/* Pinned to the bottom of the scrolling panel.\n A screenshot is tall \u2014 often taller than the viewport \u2014 so \u0E41\u0E08\u0E49\u0E07\u0E1B\u0E31\u0E0D\u0E2B\u0E32 sat\n below the fold and the last step of filing a report was \"scroll to the end\n and find the button\". Sticky, with its own ground and a rule above it so the\n annotated image scrolls underneath rather than bleeding into it. */\n.foot {\n position: sticky; bottom: 0; z-index: 3;\n display: flex; align-items: center; gap: 12px;\n margin: 4px -24px 0; padding: 12px 24px;\n background: #fff; border-top: 1px solid #e2e8f0;\n border-radius: 0 0 16px 16px;\n}\n.foot .grow { flex: 1; }\n\n/* \u0E02\u0E2D\u0E43\u0E2B\u0E49\u0E17\u0E35\u0E21\u0E14\u0E33\u0E40\u0E19\u0E34\u0E19\u0E01\u0E32\u0E23 \u2014 the same dialog turned into a request (docs/WIDGET-ASK-PLAN.md):\n \u0E41\u0E08\u0E49\u0E07\u0E1B\u0E31\u0E0D\u0E2B\u0E32 \u00B7 \u0E04\u0E33\u0E02\u0E2D as two cards under the heading, the way the desk's own\n \u0E2A\u0E23\u0E49\u0E32\u0E07\u0E40\u0E27\u0E34\u0E23\u0E4C\u0E01\u0E42\u0E1F\u0E25\u0E27\u0E4C asks it; what is asked for as cards, the way the portal lists it. */\n[hidden] { display: none !important; }\n.kinds { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: -4px; }\n/* Before anything is asked of the browser: the same two cards, a little larger, in a smaller dialog. */\n.panel.narrow { width: min(560px, 100%); }\n.kinds.choose { margin: 0 0 24px; }\n.kinds.choose button.kind { padding: 16px 18px; gap: 6px; }\n.kinds.choose .kind-hint { font-size: 13px; }\nbutton.kind {\n position: relative; display: flex; flex-direction: column; align-items: flex-start; gap: 4px;\n padding: 12px 34px 12px 14px; text-align: left; border-radius: 12px;\n}\n.kind-top { display: flex; align-items: center; gap: 8px; font-size: 15px; }\n.kind-top b { font-weight: 600; }\n.kind-hint { font-size: 13px; font-weight: 400; line-height: 1.5; color: #64748b; }\nbutton.kind .icon { width: 18px; height: 18px; flex-shrink: 0; color: #64748b; }\nbutton.kind[data-kind=\"request\"] .icon { color: #1d4ed8; }\nbutton.kind .tick { position: absolute; top: 12px; right: 12px; width: 16px; height: 16px; color: #2563eb; opacity: 0; }\nbutton.kind[aria-checked=\"true\"], button.kind[aria-checked=\"true\"]:hover:not(:disabled) {\n border-color: #2563eb; background: #eff6ff; box-shadow: inset 0 0 0 1px #2563eb;\n}\nbutton.kind[aria-checked=\"true\"] .tick { opacity: 1; }\nbutton.kind:focus-visible, button.type:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.25); }\nbutton.kind[aria-checked=\"true\"]:focus-visible { box-shadow: inset 0 0 0 1px #2563eb, 0 0 0 3px rgba(37, 99, 235, 0.25); }\n.types { display: grid; gap: 8px; }\nbutton.type {\n display: flex; align-items: flex-start; gap: 10px; width: 100%;\n padding: 10px 12px; text-align: left; border-radius: 10px;\n}\nbutton.type .icon { width: 16px; height: 16px; margin-top: 3px; flex-shrink: 0; color: #64748b; }\nbutton.type[aria-checked=\"true\"], button.type[aria-checked=\"true\"]:hover:not(:disabled) { border-color: #2563eb; background: #eff6ff; }\nbutton.type[aria-checked=\"true\"] .icon { color: #2563eb; }\n.type-body { display: grid; gap: 2px; min-width: 0; }\n.type-body b { font-size: 14px; font-weight: 600; }\n.type-hint { font-size: 13px; font-weight: 400; color: #64748b; }\n.type-due { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; font-weight: 400; color: #64748b; }\nbutton.type .type-due .icon { width: 12px; height: 12px; margin: 0; }\n/* A phone: the two kinds still side by side \u2014 their names are the choice; the hints stay short. */\n@media (max-width: 480px) {\n .kind-hint { font-size: 12px; }\n button.kind { padding: 10px 30px 10px 12px; }\n}\nbutton.link {\n display: inline; padding: 0; border: 0; border-radius: 4px; background: none;\n font-size: 13px; font-weight: 600; color: #2563eb;\n text-decoration: underline; text-underline-offset: 3px;\n}\nbutton.link:hover:not(:disabled) { background: none; color: #1d4ed8; }\nbutton.link:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }\n.ask p { margin: 0; }\n.shot-choice { display: flex; align-items: center; gap: 8px; margin: 0; font-weight: 500; }\n.shot-choice input { width: 16px; height: 16px; margin: 0; accent-color: #2563eb; }\n.dropped { opacity: 0.35; pointer-events: none; }\n.draft { margin: -8px 0 0; padding: 8px 12px; border-radius: 10px; background: #eff6ff; font-size: 13px; color: #1e3a8a; }\n";
13
+ export declare const CSS = "\n:host { all: initial; }\n* { box-sizing: border-box; }\n\n.backdrop {\n position: fixed; inset: 0; z-index: 2147483000;\n display: flex; align-items: center; justify-content: center;\n padding: 24px; background: rgba(15, 23, 42, 0.55);\n font-family: system-ui, -apple-system, \"Segoe UI\", Roboto, \"Noto Sans Thai\", sans-serif;\n color: #0f172a;\n}\n.panel {\n display: flex; flex-direction: column; gap: 16px;\n width: min(820px, 100%); max-height: 90vh; overflow-y: auto;\n /* No bottom padding: the footer supplies its own and sits flush, so it can\n stick to the panel's edge without a gap under it. */\n padding: 24px 24px 0; border-radius: 16px; background: #fff;\n box-shadow: 0 24px 60px rgba(15, 23, 42, 0.28);\n}\n.head { display: flex; align-items: flex-start; gap: 12px; }\nh2 { margin: 0; font-size: 20px; font-weight: 700; flex: 1; }\nlabel { display: block; font-size: 14px; font-weight: 600; margin-bottom: 6px; }\n.req { color: #dc2626; }\n.hint { font-weight: 400; color: #64748b; }\ninput[type=\"text\"], input[type=\"search\"], textarea, select {\n width: 100%; padding: 10px 12px; font: inherit; font-size: 14px;\n border: 1px solid #cbd5e1; border-radius: 10px; background: #fff; color: inherit;\n}\ninput[type=\"text\"]:focus, input[type=\"search\"]:focus, textarea:focus, select:focus {\n outline: none; border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18);\n}\ntextarea { min-height: 88px; resize: vertical; }\n\n.meta {\n padding: 12px 14px; border-radius: 10px; background: #f8fafc;\n font-size: 13px; line-height: 1.7; color: #475569; word-break: break-all;\n}\n.meta b { color: #0f172a; font-weight: 600; }\n\n.tools { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }\n.sep { width: 1px; align-self: stretch; background: #e2e8f0; margin: 0 4px; }\nbutton {\n display: inline-flex; align-items: center; gap: 6px; cursor: pointer;\n padding: 8px 14px; font: inherit; font-size: 14px; font-weight: 500;\n border: 1px solid #cbd5e1; border-radius: 10px; background: #fff; color: inherit;\n}\nbutton:hover:not(:disabled) { background: #f1f5f9; }\nbutton:disabled { opacity: 0.5; cursor: not-allowed; }\nbutton[aria-pressed=\"true\"] { border-color: #2563eb; background: #eff6ff; color: #1d4ed8; }\nbutton.primary { background: #2563eb; border-color: #2563eb; color: #fff; }\nbutton.primary:hover:not(:disabled) { background: #1d4ed8; }\nbutton.icon { padding: 6px 10px; border-color: transparent; font-size: 18px; line-height: 1; }\n\n/* The on-image text editor. Absolutely placed over the canvas and matched to\n the shape it becomes \u2014 same weight, same red, same white plate \u2014 so what you\n type is what gets drawn, at the position you clicked. Sized in JS, because\n the font size tracks the image's own scale. */\n.text-edit {\n position: absolute; z-index: 2; margin: 0; padding: 2px 4px;\n font-family: system-ui, sans-serif; font-weight: 600; line-height: 1.25;\n color: #e11d48; background: rgba(255, 255, 255, 0.92);\n border: 1px dashed #e11d48; border-radius: 4px; outline: none;\n box-shadow: 0 2px 10px rgba(15, 23, 42, 0.18);\n}\n.text-edit::placeholder { color: rgba(225, 29, 72, 0.45); }\n\ncanvas { width: 100%; height: auto; display: block; border: 1px solid #e2e8f0; border-radius: 10px; touch-action: none; }\n.shot { position: relative; }\n.status { font-size: 13px; color: #64748b; }\n.status[data-tone=\"error\"] { color: #dc2626; }\n/* Pinned to the bottom of the scrolling panel.\n A screenshot is tall \u2014 often taller than the viewport \u2014 so \u0E41\u0E08\u0E49\u0E07\u0E1B\u0E31\u0E0D\u0E2B\u0E32 sat\n below the fold and the last step of filing a report was \"scroll to the end\n and find the button\". Sticky, with its own ground and a rule above it so the\n annotated image scrolls underneath rather than bleeding into it. */\n.foot {\n position: sticky; bottom: 0; z-index: 3;\n display: flex; align-items: center; gap: 12px;\n margin: 4px -24px 0; padding: 12px 24px;\n background: #fff; border-top: 1px solid #e2e8f0;\n border-radius: 0 0 16px 16px;\n}\n.foot .grow { flex: 1; }\n\n/* \u0E02\u0E2D\u0E43\u0E2B\u0E49\u0E17\u0E35\u0E21\u0E14\u0E33\u0E40\u0E19\u0E34\u0E19\u0E01\u0E32\u0E23 \u2014 the same dialog turned into a request (docs/WIDGET-ASK-PLAN.md):\n \u0E41\u0E08\u0E49\u0E07\u0E1B\u0E31\u0E0D\u0E2B\u0E32 \u00B7 \u0E04\u0E33\u0E02\u0E2D as two cards under the heading, the way the desk's own\n \u0E2A\u0E23\u0E49\u0E32\u0E07\u0E40\u0E27\u0E34\u0E23\u0E4C\u0E01\u0E42\u0E1F\u0E25\u0E27\u0E4C asks it; what is asked for as cards, the way the portal lists it. */\n[hidden] { display: none !important; }\n/* The press's choice: two cards in a smaller dialog, before anything is asked of the browser. */\n.kinds { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }\n/* In the form, the choice is made: one quiet line under the heading to switch. */\n.door { margin: -8px 0 0; font-size: 13px; color: #64748b; }\n.panel.narrow { width: min(560px, 100%); }\n.kinds.choose { margin: 0 0 24px; }\n.kinds.choose button.kind { padding: 16px 18px; gap: 6px; }\n.kinds.choose .kind-hint { font-size: 13px; }\nbutton.kind {\n position: relative; display: flex; flex-direction: column; align-items: flex-start; gap: 4px;\n padding: 12px 34px 12px 14px; text-align: left; border-radius: 12px;\n}\n.kind-top { display: flex; align-items: center; gap: 8px; font-size: 15px; }\n.kind-top b { font-weight: 600; }\n.kind-hint { font-size: 13px; font-weight: 400; line-height: 1.5; color: #64748b; }\nbutton.kind .icon { width: 18px; height: 18px; flex-shrink: 0; color: #64748b; }\nbutton.kind[data-kind=\"request\"] .icon { color: #1d4ed8; }\nbutton.kind .tick { position: absolute; top: 12px; right: 12px; width: 16px; height: 16px; color: #2563eb; opacity: 0; }\nbutton.kind[aria-checked=\"true\"], button.kind[aria-checked=\"true\"]:hover:not(:disabled) {\n border-color: #2563eb; background: #eff6ff; box-shadow: inset 0 0 0 1px #2563eb;\n}\nbutton.kind[aria-checked=\"true\"] .tick { opacity: 1; }\nbutton.kind:focus-visible, button.type:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.25); }\nbutton.kind[aria-checked=\"true\"]:focus-visible { box-shadow: inset 0 0 0 1px #2563eb, 0 0 0 3px rgba(37, 99, 235, 0.25); }\n.types { display: grid; gap: 6px; }\n/* A long list scrolls in its own height, so the title and \u0E2A\u0E48\u0E07\u0E04\u0E33\u0E02\u0E2D stay near. */\n.types.long { max-height: 300px; overflow-y: auto; padding: 2px; margin: -2px; overscroll-behavior: contain; }\n.type-search { margin-bottom: 8px; }\n.ask .type-count { margin: 6px 0 0; font-size: 12px; }\n/* The rest are one line: only the chosen one opens up with its words and the team's time. */\nbutton.type:not([aria-checked=\"true\"]) { padding: 8px 12px; align-items: center; }\nbutton.type:not([aria-checked=\"true\"]) .type-hint, button.type:not([aria-checked=\"true\"]) .type-due { display: none; }\nbutton.type:not([aria-checked=\"true\"]) .type-body b { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\nbutton.type:not([aria-checked=\"true\"]) .icon { margin-top: 0; }\nbutton.type {\n display: flex; align-items: flex-start; gap: 10px; width: 100%;\n padding: 10px 12px; text-align: left; border-radius: 10px;\n}\nbutton.type .icon { width: 16px; height: 16px; margin-top: 3px; flex-shrink: 0; color: #64748b; }\nbutton.type[aria-checked=\"true\"], button.type[aria-checked=\"true\"]:hover:not(:disabled) { border-color: #2563eb; background: #eff6ff; }\nbutton.type[aria-checked=\"true\"] .icon { color: #2563eb; }\n.type-body { display: grid; gap: 2px; min-width: 0; }\n.type-body b { font-size: 14px; font-weight: 600; }\n.type-hint { font-size: 13px; font-weight: 400; color: #64748b; }\n.type-due { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; font-weight: 400; color: #64748b; }\nbutton.type .type-due .icon { width: 12px; height: 12px; margin: 0; }\n/* A phone: the two kinds still side by side \u2014 their names are the choice; the hints stay short. */\n@media (max-width: 480px) {\n .kind-hint { font-size: 12px; }\n button.kind { padding: 10px 30px 10px 12px; }\n}\nbutton.link {\n display: inline; padding: 0; border: 0; border-radius: 4px; background: none;\n font-size: 13px; font-weight: 600; color: #2563eb;\n text-decoration: underline; text-underline-offset: 3px;\n}\nbutton.link:hover:not(:disabled) { background: none; color: #1d4ed8; }\nbutton.link:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }\n.ask p { margin: 0; }\n.shot-choice { display: flex; align-items: center; gap: 8px; margin: 0; font-weight: 500; }\n.shot-choice input { width: 16px; height: 16px; margin: 0; accent-color: #2563eb; }\n.dropped { opacity: 0.35; pointer-events: none; }\n.draft { margin: -8px 0 0; padding: 8px 12px; border-radius: 10px; background: #eff6ff; font-size: 13px; color: #1e3a8a; }\n";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightworkai.official/debug-capture",
3
- "version": "0.14.2",
3
+ "version": "0.15.0",
4
4
  "description": "Report-a-problem widget: rolling network/console/action capture, optional rrweb session replay, annotated screenshot, and submission to a Lightwork Support host",
5
5
  "type": "module",
6
6
  "module": "./dist/index.mjs",
package/src/config.ts CHANGED
@@ -149,6 +149,18 @@ export interface DebugCaptureConfig {
149
149
  /** UI language. Only affects the built-in reporter's own strings. */
150
150
  locale?: "th" | "en";
151
151
 
152
+ /**
153
+ * The ring for a call from the support team. Default true.
154
+ *
155
+ * With `identity` set and calls switched on for the desk, the reporter's own
156
+ * live connection to the support host is held here — the one their portal
157
+ * tab holds, proven with the same signed token — so a call from the team
158
+ * rings in this app, as a card, and opens the reporter's request on the
159
+ * support host to answer it (the call itself happens there). `false` leaves
160
+ * calls to the support host and the invitation the team sends.
161
+ */
162
+ ring?: boolean;
163
+
152
164
  /**
153
165
  * Where this library's own notifications go.
154
166
  *
package/src/index.ts CHANGED
@@ -42,6 +42,8 @@ export { redactBody, redactHeaders, redactLabel, redactUrl, REDACTED } from "./c
42
42
  // `defineReporterElement()` builds it in the browser, where it exists.
43
43
  export { defineReporterElement, setErroredQueriesSource, REPORTER_TAG } from "./ui/element";
44
44
  export { showToast } from "./ui/toast";
45
+ // A call from the support team, rung in the host app — started by install; a host that renders its own UI may start or stop it.
46
+ export { startLiveRing, stopLiveRing } from "./live";
45
47
 
46
48
  /*
47
49
  * The annotator ENGINE, shared by every framework package.
package/src/install.ts CHANGED
@@ -14,6 +14,7 @@ import { strings } from "./ui/strings";
14
14
  import { showToast } from "./ui/toast";
15
15
  import { defineReporterElement, REPORTER_TAG } from "./ui/element";
16
16
  import { loadRequestTypes } from "./requests";
17
+ import { startLiveRing } from "./live";
17
18
 
18
19
  let armed = false;
19
20
 
@@ -69,4 +70,12 @@ export function installDebugCapture(options: InstallOptions = {}): void {
69
70
  const idle = (window as Window & { requestIdleCallback?: (cb: () => void, o?: { timeout: number }) => number }).requestIdleCallback;
70
71
  if (idle) idle(() => void loadRequestTypes(), { timeout: 5000 });
71
72
  else setTimeout(() => void loadRequestTypes(), 2000);
73
+
74
+ /*
75
+ * A call from the support team rings here (live.ts) — when the host vouches
76
+ * for who is signed in and the desk has calls on. Late, like the request
77
+ * types: the page came for something else.
78
+ */
79
+ if (idle) idle(() => startLiveRing(), { timeout: 8000 });
80
+ else setTimeout(() => startLiveRing(), 3000);
72
81
  }
package/src/live.ts ADDED
@@ -0,0 +1,227 @@
1
+ /**
2
+ * The ring for a call from the support team, in the host app.
3
+ *
4
+ * A reporter lives in the app they report from, not on the support host; a
5
+ * call from the team used to reach them only by the invitation in their
6
+ * email. So, when the host vouches for who is signed in (`identity`) and the
7
+ * desk has calls switched on, the widget holds the reporter's OWN live
8
+ * connection to the support host — the one their portal tab holds, traded
9
+ * for the host's signed token at `/ingest/live-ticket`, told about their own
10
+ * requests and calls and nothing else — and a call rings here as a card.
11
+ *
12
+ * Answering happens on the support host, where the call's media lives: the
13
+ * card's button opens the reporter's request there, signed in by the same
14
+ * hand-off the portal button uses — a tab opened INSIDE the press (a browser
15
+ * allows nothing later), its address set once the token is in hand.
16
+ *
17
+ * Quiet by design. No identity, calls off, a support host older than this
18
+ * package, a token refused: no socket, no card, nothing in the console — the
19
+ * widget does exactly what it did before.
20
+ */
21
+ import { getConfig } from "./config";
22
+ import { preparePortalUrl } from "./portal";
23
+ import { hideRing, ringOpened, ringShownFor, showRing } from "./ui/ring";
24
+
25
+ export interface RingingCall {
26
+ id: string;
27
+ ticketId: string;
28
+ ticketKey: string;
29
+ ticketTitle: string;
30
+ status: string;
31
+ ringUntil: string;
32
+ side?: string | null;
33
+ }
34
+
35
+ const RETRY_MS = [2000, 5000, 15_000, 30_000, 60_000];
36
+ const PING_MS = 20_000;
37
+
38
+ let started = false;
39
+ let stopped = false;
40
+ /** Asked for a token and got none — nobody signed in yet. A few slow tries, then quiet: never a request a minute forever. */
41
+ let tokenless = 0;
42
+ const TOKENLESS_MAX = 5;
43
+ let socket: WebSocket | null = null;
44
+ let retry = 0;
45
+ let pinger: ReturnType<typeof setInterval> | undefined;
46
+ let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
47
+ let expiry: ReturnType<typeof setTimeout> | undefined;
48
+
49
+ const base = (host: string) => (host.endsWith("/") ? host : `${host}/`);
50
+
51
+ /** `wss://support.example/api/presence/ws?t=…` — the support host's scheme, the socket's own path. Pure, for the tests. */
52
+ export function liveSocketUrl(host: string, ticket: string): string {
53
+ const url = new URL("api/presence/ws", base(host));
54
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
55
+ url.searchParams.set("t", ticket);
56
+ return url.toString();
57
+ }
58
+
59
+ /** Where the card sends them: their request, with the call — the support host rings it there. Pure, for the tests. */
60
+ export function answerPath(call: Pick<RingingCall, "id" | "ticketId">): string {
61
+ return `/me/tickets/${call.ticketId}?call=${call.id}`;
62
+ }
63
+
64
+ /** Is this a call ringing for the person at this widget? */
65
+ export function ringsHere(call: Pick<RingingCall, "status" | "side">): boolean {
66
+ return call.status === "ringing" && (call.side === "reporter" || call.side === undefined || call.side === null);
67
+ }
68
+
69
+ async function hostToken(): Promise<string | null> {
70
+ const provider = getConfig().identity;
71
+ if (!provider) return null;
72
+ try {
73
+ return (await provider()) ?? null;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ async function post(path: string, token: string): Promise<Response> {
80
+ const config = getConfig();
81
+ return fetch(new URL(path, base(config.host)).toString(), {
82
+ method: "POST",
83
+ headers: { "content-type": "application/json", "x-realm-key": config.realmKey, authorization: `Bearer ${token}` },
84
+ body: "{}",
85
+ });
86
+ }
87
+
88
+ /** Start listening for calls. Idempotent; a no-op without identity, or with `ring: false`. */
89
+ export function startLiveRing(): void {
90
+ if (started || typeof window === "undefined" || typeof WebSocket === "undefined") return;
91
+ const config = getConfig();
92
+ if (!config.identity || config.ring === false) return;
93
+ started = true;
94
+ stopped = false;
95
+ void connect();
96
+ }
97
+
98
+ /** Let go — the socket, the card, the timers. For a host that unmounts, and for the tests. */
99
+ export function stopLiveRing(): void {
100
+ stopped = true;
101
+ started = false;
102
+ clearTimeout(reconnectTimer);
103
+ clearInterval(pinger);
104
+ clearTimeout(expiry);
105
+ try {
106
+ socket?.close(1000, "bye");
107
+ } catch {
108
+ /* already */
109
+ }
110
+ socket = null;
111
+ hideRing();
112
+ }
113
+
114
+ function schedule(slow = false): void {
115
+ if (stopped) return;
116
+ const wait = slow ? RETRY_MS[RETRY_MS.length - 1]! : RETRY_MS[Math.min(retry++, RETRY_MS.length - 1)]!;
117
+ clearTimeout(reconnectTimer);
118
+ reconnectTimer = setTimeout(() => void connect(), wait);
119
+ }
120
+
121
+ async function connect(): Promise<void> {
122
+ if (stopped) return;
123
+ const token = await hostToken();
124
+ // Nobody signed in to the host yet: ask again, slowly — a few times, then stop.
125
+ if (!token) {
126
+ if (++tokenless >= TOKENLESS_MAX) {
127
+ stopped = true;
128
+ return;
129
+ }
130
+ return schedule(true);
131
+ }
132
+ tokenless = 0;
133
+ let res: Response;
134
+ try {
135
+ res = await post("ingest/live-ticket", token);
136
+ } catch {
137
+ return schedule();
138
+ }
139
+ // A token the desk does not accept, or a support host from before the ring: nothing to do, and nothing to say.
140
+ if (res.status === 401 || res.status === 404 || res.status === 403) {
141
+ stopped = true;
142
+ return;
143
+ }
144
+ if (!res.ok) return schedule(res.status === 429);
145
+ let out: { calls?: boolean; ticket?: string | null; call?: RingingCall | null };
146
+ try {
147
+ out = await res.json();
148
+ } catch {
149
+ return schedule();
150
+ }
151
+ // Calls off for this desk: no socket at all.
152
+ if (!out.calls || !out.ticket) {
153
+ stopped = true;
154
+ return;
155
+ }
156
+ if (out.call) hear(out.call);
157
+ const ws = new WebSocket(liveSocketUrl(getConfig().host, out.ticket));
158
+ socket = ws;
159
+ ws.onopen = () => {
160
+ retry = 0;
161
+ clearInterval(pinger);
162
+ pinger = setInterval(() => {
163
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" }));
164
+ }, PING_MS);
165
+ };
166
+ ws.onmessage = (e) => {
167
+ try {
168
+ const frame = JSON.parse(String(e.data)) as { type?: string; call?: RingingCall };
169
+ if (frame.type === "call-state" && frame.call) hear(frame.call);
170
+ } catch {
171
+ /* not ours to read */
172
+ }
173
+ };
174
+ ws.onclose = () => {
175
+ clearInterval(pinger);
176
+ if (socket === ws) socket = null;
177
+ schedule();
178
+ };
179
+ }
180
+
181
+ /** The support host's word on a call: ringing here shows the card; anything else takes it away. */
182
+ function hear(call: RingingCall): void {
183
+ if (!ringsHere(call)) return hideRing(call.id);
184
+ showRing(call, { open: () => answer(call), decline: () => void decline(call) });
185
+ // Never longer than it rings: a socket that dropped mid-ring must not leave a card up for good.
186
+ clearTimeout(expiry);
187
+ const left = new Date(call.ringUntil).getTime() - Date.now();
188
+ expiry = setTimeout(() => {
189
+ if (ringShownFor() === call.id) hideRing(call.id);
190
+ }, Math.max(5000, left + 5000));
191
+ }
192
+
193
+ /**
194
+ * เปิดเพื่อรับสาย. The tab is opened HERE, inside the press — after an await the
195
+ * browser would call it an unasked-for popup — then sent to the signed-in
196
+ * link once the host's token arrives. The support host is told the reporter
197
+ * is coming, so the ring waits for the hand-off and the team sees it.
198
+ */
199
+ function answer(call: RingingCall): void {
200
+ const tab = window.open("about:blank", "_blank");
201
+ void (async () => {
202
+ const token = await hostToken();
203
+ if (token) void post(`ingest/calls/${call.id}/coming`, token).catch(() => undefined);
204
+ let url: string;
205
+ try {
206
+ url = await preparePortalUrl(answerPath(call));
207
+ } catch {
208
+ tab?.close();
209
+ return;
210
+ }
211
+ if (tab && !tab.closed) {
212
+ tab.location.href = url;
213
+ try {
214
+ tab.opener = null;
215
+ } catch {
216
+ /* open either way */
217
+ }
218
+ ringOpened();
219
+ } else ringOpened(url);
220
+ })();
221
+ }
222
+
223
+ async function decline(call: RingingCall): Promise<void> {
224
+ hideRing(call.id);
225
+ const token = await hostToken();
226
+ if (token) await post(`ingest/calls/${call.id}/decline`, token).catch(() => undefined);
227
+ }
package/src/portal.ts CHANGED
@@ -79,6 +79,9 @@ async function prepareUrl(returnTo?: string): Promise<string> {
79
79
  return supportPortalUrl(config.host, config.realmKey, token, returnTo);
80
80
  }
81
81
 
82
+ /** The signed-in link to a path on the support host — for a caller that opened its own tab first (the call ring, ui/ring). */
83
+ export const preparePortalUrl = prepareUrl;
84
+
82
85
  /** False when the browser refused — a popup blocker, almost always. */
83
86
  function navigate(url: string, target: OpenPortalOptions["target"]): boolean {
84
87
  if (target === "_self") {
package/src/ui/element.ts CHANGED
@@ -311,41 +311,47 @@ function createReporterClass(): CustomElementConstructor {
311
311
  head.append(heading, closeBtn);
312
312
 
313
313
  /*
314
- * แจ้งปัญหา or คำขอ — the same two cards the press chose from, kept under
315
- * the heading so the form can be switched any time with nothing typed
316
- * lost (to แจ้งปัญหา from a request that began with no screen, the
317
- * share is asked for then — switchToReport). Shown only where the desk
318
- * offers something to ask.
314
+ * The kind was chosen at the press (renderChooser); the form names it in
315
+ * its heading and keeps one quiet line to switch — the owner: "no need
316
+ * to be card style since that select it on the first dialog". Switching
317
+ * keeps what was typed; to แจ้งปัญหา from a request that began with no
318
+ * screen, the share is asked for at that press (switchToReport). Shown
319
+ * only where the desk offers something to ask.
319
320
  */
320
- const kinds = el("div", "kinds");
321
- kinds.hidden = true;
322
- kinds.setAttribute("role", "radiogroup");
323
- kinds.setAttribute("aria-label", t.chooseTitle);
324
- const kindCard = (kind: "problem" | "request", icon: IconNode, title: string, hint: string) => {
325
- const card = kindButton(kind, icon, title, hint);
326
- card.setAttribute("role", "radio");
327
- card.addEventListener("click", () => setMode(kind));
328
- return card;
329
- };
330
- // From a request that began with no screen, แจ้งปัญหา will ask for it — said on the card, as the chooser says it.
331
- const problemCard = kindCard("problem", BUG, t.kindProblem, screenTaken ? t.kindProblemHint : t.chooseProblemHint);
332
- const requestCard = kindCard("request", WORKFLOW, t.kindRequest, t.kindRequestHint);
333
- kinds.append(problemCard, requestCard);
334
- // A radio group's own keys: an arrow moves the choice, and the focus with it.
335
- kinds.addEventListener("keydown", (e) => {
336
- if (!ARROWS.includes(e.key)) return;
337
- e.preventDefault();
338
- setMode(mode === "problem" ? "request" : "problem");
339
- (mode === "problem" ? problemCard : requestCard).focus();
340
- });
321
+ const door = el("p", "door");
322
+ door.hidden = true;
323
+ const doorText = el("span");
324
+ const doorBtn = button("", "link");
325
+ doorBtn.dataset.switchKind = "";
326
+ doorBtn.addEventListener("click", () => setMode(mode === "problem" ? "request" : "problem"));
327
+ door.append(doorText, doorBtn);
341
328
 
342
- // What is asked for: one card each — the type's own words and how long the team means to take, as the portal shows them.
329
+ /*
330
+ * What is asked for: a row each. The one chosen opens up — its words and
331
+ * how long the team means to take, as the portal shows them — and the
332
+ * rest stay one line, so a desk with 10 or 20 types is still a list to
333
+ * read, not a page to scroll. From SEARCH_FROM types there is a search
334
+ * over them, and the list scrolls within its own height.
335
+ */
343
336
  const ask = el("div", "ask");
344
337
  ask.hidden = true;
338
+ const typeSearch = document.createElement("input");
339
+ // A search, not a text field: the title stays the dialog's one text input.
340
+ typeSearch.type = "search";
341
+ typeSearch.className = "type-search";
342
+ typeSearch.placeholder = t.requestTypeSearch;
343
+ typeSearch.setAttribute("aria-label", t.requestTypeSearch);
344
+ typeSearch.dataset.requestTypeSearch = "";
345
+ typeSearch.hidden = true;
345
346
  const typeList = el("div", "types");
346
347
  typeList.setAttribute("role", "radiogroup");
347
348
  typeList.setAttribute("aria-label", t.requestTypeLabel);
348
- ask.append(labelFor(t.requestTypeLabel, "", true), typeList);
349
+ const typeNone = el("p", "status", t.requestTypeNone);
350
+ typeNone.hidden = true;
351
+ // A long list says how many it holds — the rows past its edge are otherwise unseen.
352
+ const typeCount = el("p", "status type-count", "");
353
+ typeCount.hidden = true;
354
+ ask.append(labelFor(t.requestTypeLabel, "", true), typeSearch, typeList, typeNone, typeCount);
349
355
  const typeCards: HTMLButtonElement[] = [];
350
356
  const markType = () => {
351
357
  for (const card of typeCards) {
@@ -353,6 +359,13 @@ function createReporterClass(): CustomElementConstructor {
353
359
  card.setAttribute("aria-checked", String(on));
354
360
  card.tabIndex = on ? 0 : -1;
355
361
  }
362
+ // The chosen one in view inside the list's own scroll, never the dialog's.
363
+ const on = typeCards.find((c) => c.dataset.requestType === chosen?.id);
364
+ if (on && typeList.scrollHeight > typeList.clientHeight) {
365
+ const top = on.offsetTop - typeList.offsetTop;
366
+ if (top < typeList.scrollTop) typeList.scrollTop = top;
367
+ else if (top + on.offsetHeight > typeList.scrollTop + typeList.clientHeight) typeList.scrollTop = top + on.offsetHeight - typeList.clientHeight;
368
+ }
356
369
  };
357
370
 
358
371
  const summary = document.createElement("input");
@@ -408,12 +421,11 @@ function createReporterClass(): CustomElementConstructor {
408
421
 
409
422
  const apply = () => {
410
423
  const asking = mode === "request";
411
- heading.textContent = kinds.hidden ? t.title : t.chooseTitle;
424
+ heading.textContent = asking ? t.requestTitle : t.title;
412
425
  panel.setAttribute("aria-label", asking ? t.requestTitle : t.title);
413
- for (const [card, on] of [[problemCard, !asking], [requestCard, asking]] as const) {
414
- card.setAttribute("aria-checked", String(on));
415
- card.tabIndex = on ? 0 : -1;
416
- }
426
+ doorText.textContent = `${asking ? t.problemInstead : t.askInstead} `;
427
+ // From a request that began with no screen, แจ้งปัญหา will ask for it — said on the line.
428
+ doorBtn.textContent = asking ? (screenTaken ? t.problemInsteadAction : t.problemInsteadShare) : t.askInsteadAction;
417
429
  ask.hidden = !asking;
418
430
  fillLabel(summaryLabel, asking ? t.requestSummaryLabel : t.summaryLabel, asking ? "" : t.summaryRequired, true);
419
431
  summary.placeholder = asking ? t.requestSummaryPlaceholder : t.summaryPlaceholder;
@@ -472,17 +484,49 @@ function createReporterClass(): CustomElementConstructor {
472
484
  typeCards.push(card);
473
485
  }
474
486
  typeList.replaceChildren(...typeCards);
487
+ // ↑ ↓ among the rows the search leaves.
475
488
  typeList.onkeydown = (e) => {
476
489
  if (!ARROWS.includes(e.key) || !chosen) return;
477
490
  e.preventDefault();
478
- const at = types.findIndex((x) => x.id === chosen!.id);
491
+ const shown = types.filter((x, i) => !typeCards[i]!.hidden);
492
+ if (!shown.length) return;
493
+ const at = shown.findIndex((x) => x.id === chosen!.id);
479
494
  const step = e.key === "ArrowUp" || e.key === "ArrowLeft" ? -1 : 1;
480
- chosen = types[(at + step + types.length) % types.length]!;
495
+ chosen = shown[at < 0 ? 0 : (at + step + shown.length) % shown.length]!;
481
496
  markType();
482
497
  typeCards.find((c) => c.dataset.requestType === chosen!.id)?.focus();
483
498
  };
499
+ typeList.classList.toggle("long", types.length >= SEARCH_FROM);
500
+ typeSearch.hidden = types.length < SEARCH_FROM;
501
+ const count = (n: number) => {
502
+ typeCount.hidden = types.length < SEARCH_FROM || n === 0;
503
+ typeCount.textContent = t.requestTypeCount.replace("{n}", String(n));
504
+ };
505
+ count(types.length);
506
+ typeSearch.oninput = () => {
507
+ const q = typeSearch.value.trim().toLowerCase();
508
+ let first: RequestTypeOption | null = null;
509
+ types.forEach((x, i) => {
510
+ const hit = !q || `${x.name} ${x.description}`.toLowerCase().includes(q);
511
+ typeCards[i]!.hidden = !hit;
512
+ if (hit && !first) first = x;
513
+ });
514
+ typeNone.hidden = !!first;
515
+ count(typeCards.filter((c) => !c.hidden).length);
516
+ // The choice follows the search when the one chosen is filtered out.
517
+ if (first && chosen && typeCards[types.indexOf(chosen)]!.hidden) chosen = first;
518
+ markType();
519
+ ready();
520
+ };
521
+ // Enter in the search goes to the chosen row, not the send button.
522
+ typeSearch.onkeydown = (e) => {
523
+ if (e.key === "ArrowDown" || e.key === "Enter") {
524
+ e.preventDefault();
525
+ typeCards.find((c) => c.dataset.requestType === chosen?.id && !c.hidden)?.focus();
526
+ }
527
+ };
484
528
  markType();
485
- kinds.hidden = false;
529
+ door.hidden = false;
486
530
  apply();
487
531
  };
488
532
  /*
@@ -511,7 +555,7 @@ function createReporterClass(): CustomElementConstructor {
511
555
  const offerAndRestore = (types: RequestTypeOption[]) => {
512
556
  offer(types);
513
557
  // The kind is the press's choice; a request's type comes back as it was.
514
- if (mode !== "request" || kinds.hidden || !restored?.typeId) return;
558
+ if (mode !== "request" || door.hidden || !restored?.typeId) return;
515
559
  const was = types.find((x) => x.id === restored.typeId);
516
560
  if (was) {
517
561
  chosen = was;
@@ -522,7 +566,7 @@ function createReporterClass(): CustomElementConstructor {
522
566
  else if (reason.type === "manual") void loadRequestTypes().then(offerAndRestore);
523
567
  this.keep = () => ({ title: summary.value, detail: detail.value, asking: mode === "request", typeId: chosen?.id ?? null });
524
568
 
525
- panel.append(head, kinds, again, ask, summaryLabel, summary, detailLabel, detail, meta, shotChoice, ...shot);
569
+ panel.append(head, door, again, ask, summaryLabel, summary, detailLabel, detail, meta, shotChoice, ...shot);
526
570
  const foot = el("div", "foot");
527
571
  const cancel = button(t.close);
528
572
  cancel.addEventListener("click", () => this.close());
@@ -656,6 +700,8 @@ function hoursWords(t: Strings, h: number): string {
656
700
  }
657
701
 
658
702
  const ARROWS = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"];
703
+ /** From this many request types, the list gets a search and scrolls in its own height. */
704
+ const SEARCH_FROM = 7;
659
705
 
660
706
  /*
661
707
  * The desk's own icons (lucide, as its สร้างเวิร์กโฟลว์ and the portal draw