@sphoro/bugcatch-web-sdk 0.18.0 → 0.20.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/src/widget.js CHANGED
@@ -15,6 +15,7 @@
15
15
  import * as ICONS from './icons.js';
16
16
  import { loadPanel, loadCall } from './lazy.js';
17
17
  import { noticeNode } from './notice.js';
18
+ import { readableOn } from './accent.js';
18
19
 
19
20
  const POSITIONS = {
20
21
  'bottom-right': 'bottom:20px;right:20px;',
@@ -50,86 +51,119 @@ function writeStoredTheme(value) {
50
51
  } catch { /* storage denied — the choice just won't outlive the page */ }
51
52
  }
52
53
 
54
+ // The dark palette, said once and applied twice: when the site or the visitor
55
+ // picks dark, and when 'auto' meets a system set to dark.
56
+ const DARK = `
57
+ --bc-bg: #151517;
58
+ --bc-elevated: #1c1c1f;
59
+ --bc-fg: #f3f2ef;
60
+ --bc-muted: #b3b0aa;
61
+ --bc-faint: #8f8c86;
62
+ --bc-border: #2c2c30;
63
+ --bc-subtle: #1f1f22;
64
+ --bc-stone: #26262a;
65
+ --bc-raised: #303035;
66
+ --bc-danger: #ff6b81;
67
+ --bc-ok: #4fd19a;
68
+ --bc-warn: #f2a54a;
69
+ --bc-info: #79a8ff;
70
+ --bc-brand-ink: #b3b0aa;
71
+ --bc-brand-mark: #a59cff;
72
+ --bc-shadow: 0 0 0 1px rgba(255,255,255,.08), 0 30px 70px -20px rgba(0,0,0,.8), 0 8px 20px -8px rgba(0,0,0,.5);
73
+ --bc-shadow-sm: 0 0 0 1px rgba(255,255,255,.07), 0 1px 2px rgba(0,0,0,.4);
74
+ --bc-accent-soft: color-mix(in srgb, var(--bc-accent) 14%, var(--bc-bg));`;
75
+
53
76
  // The launcher's stylesheet: the theme, the base rules, the dock. The panel's
54
77
  // arrives with the panel (panel/styles.js) and is added after this one.
78
+ //
79
+ // `accent` is the host's colour, or null for ink (accent.js). Ink is drawn
80
+ // from the theme's own foreground, so it turns white on dark by itself; a
81
+ // configured colour stays that colour in both.
55
82
  const css = (accent) => `
56
83
  :host { all: initial; }
57
84
  :host {
58
- --bc-accent: ${accent};
59
- --bc-on-accent: #fff;
60
- --bc-bg: #fff;
61
- --bc-elevated: #fff;
62
- --bc-fg: #14161c;
63
- --bc-muted: #5f6673;
64
- --bc-faint: #9aa1ad;
65
- --bc-border: #e6e8ee;
66
- --bc-subtle: #f4f5f8;
67
- --bc-raised: #fff;
68
- --bc-danger: #dc2626;
69
- --bc-ok: #059669;
70
- --bc-warn: #b45309;
85
+ --bc-accent: ${accent || 'var(--bc-fg)'};
86
+ --bc-on-accent: ${accent ? readableOn(accent) : 'var(--bc-bg)'};
87
+ /* The assistant's orb: BugCatch's own three colours, or the host's accent
88
+ with two lighter cuts of it, so the orb on their site is in their colour. */
89
+ --bc-o1: ${accent ? `color-mix(in srgb, ${accent} 32%, #fff)` : '#ffb3c9'};
90
+ --bc-o2: ${accent || '#6a5cff'};
91
+ --bc-o3: ${accent ? `color-mix(in srgb, ${accent} 22%, #ffe7d1)` : '#ffd6a0'};
92
+ --bc-bg: #ffffff;
93
+ --bc-elevated: #ffffff;
94
+ --bc-fg: #0e0e10;
95
+ --bc-muted: #57544e;
96
+ --bc-faint: #75726c;
97
+ --bc-border: #e9e7e3;
98
+ --bc-subtle: #f6f5f3;
99
+ /* Warm stone: fields, chips and the selected track. */
100
+ --bc-stone: #f0efec;
101
+ --bc-raised: #ffffff;
102
+ --bc-danger: #c8233d;
103
+ --bc-ok: #0b7a55;
104
+ --bc-warn: #a4570a;
105
+ --bc-info: #2459d1;
71
106
  /* The lockup's own two colours, kept off --bc-accent on purpose: that one is
72
107
  the customer's brand, and this mark is ours. Values are the light cut of
73
108
  brand/sphoro-bugcatch-primary.svg, pulled up from near-black so the mark
74
109
  identifies itself without out-shouting the host's page. */
75
- --bc-brand-ink: #4b5563;
110
+ --bc-brand-ink: #57544e;
76
111
  --bc-brand-mark: #5647ee;
77
- --bc-shadow: 0 24px 64px rgba(10,12,16,.22), 0 4px 14px rgba(10,12,16,.08);
78
- --bc-shadow-sm: 0 1px 2px rgba(10,12,16,.06), 0 1px 1px rgba(10,12,16,.04);
79
- --bc-dur: 240ms;
112
+ --bc-shadow: 0 0 0 1px rgba(20,18,14,.06), 0 30px 70px -24px rgba(20,18,14,.30), 0 8px 20px -8px rgba(20,18,14,.10);
113
+ --bc-shadow-sm: 0 0 0 1px rgba(20,18,14,.06), 0 1px 2px rgba(20,18,14,.05);
114
+ --bc-dur: 220ms;
80
115
  --bc-ease: cubic-bezier(.22,1,.36,1);
116
+ /* Film grain over the orb, so it reads as light rather than a gradient. */
117
+ --bc-grain: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 .6 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
81
118
  /* The system everything below is drawn from. Derived tokens are written in
82
119
  terms of the ones above, so a host that overrides --bc-accent or the
83
120
  palette gets a coherent result without touching these. */
84
- --bc-accent-soft: color-mix(in srgb, var(--bc-accent) 10%, var(--bc-bg));
85
- --bc-accent-line: color-mix(in srgb, var(--bc-accent) 32%, var(--bc-border));
86
- --bc-ring: 0 0 0 3px color-mix(in srgb, var(--bc-accent) 22%, transparent);
121
+ --bc-accent-soft: color-mix(in srgb, var(--bc-accent) 7%, var(--bc-bg));
122
+ --bc-accent-line: color-mix(in srgb, var(--bc-accent) 30%, var(--bc-border));
123
+ --bc-ring: 0 0 0 3px color-mix(in srgb, var(--bc-accent) 16%, transparent);
87
124
  --bc-r-sm: 8px;
88
125
  --bc-r-md: 12px;
89
- --bc-r-lg: 18px;
90
- --bc-r-xl: 22px;
126
+ --bc-r-lg: 16px;
127
+ --bc-r-xl: 20px;
91
128
  --bc-fs-xs: 11.5px;
92
129
  --bc-fs-sm: 12.5px;
93
130
  --bc-fs-md: 14px;
94
- --bc-fs-lg: 16px;
131
+ --bc-fs-lg: 15px;
95
132
  --bc-pad: 18px;
96
133
  }
97
- :host(.bc-dark) {
98
- --bc-bg: #14161c;
99
- --bc-elevated: #1c1f27;
100
- --bc-fg: #eef0f4;
101
- --bc-muted: #a3aab6;
102
- --bc-faint: #6f7682;
103
- --bc-border: #2a2e38;
104
- --bc-subtle: #1f222a;
105
- --bc-raised: #363d4b;
106
- --bc-warn: #f59e0b;
107
- --bc-brand-ink: #9aa1ad;
108
- --bc-brand-mark: #9b8fff;
109
- --bc-shadow: 0 24px 64px rgba(0,0,0,.6), 0 4px 14px rgba(0,0,0,.4);
110
- --bc-shadow-sm: 0 1px 2px rgba(0,0,0,.4);
111
- --bc-accent-soft: color-mix(in srgb, var(--bc-accent) 18%, var(--bc-bg));
134
+ :host(.bc-dark) {${DARK}
112
135
  }
113
136
  @media (prefers-color-scheme: dark) {
114
- :host(.bc-auto) {
115
- --bc-bg: #14161c;
116
- --bc-elevated: #1c1f27;
117
- --bc-fg: #eef0f4;
118
- --bc-muted: #a3aab6;
119
- --bc-faint: #6f7682;
120
- --bc-border: #2a2e38;
121
- --bc-subtle: #1f222a;
122
- --bc-raised: #363d4b;
123
- --bc-warn: #f59e0b;
124
- --bc-brand-ink: #9aa1ad;
125
- --bc-brand-mark: #9b8fff;
126
- --bc-shadow: 0 24px 64px rgba(0,0,0,.6), 0 4px 14px rgba(0,0,0,.4);
127
- --bc-shadow-sm: 0 1px 2px rgba(0,0,0,.4);
128
- --bc-accent-soft: color-mix(in srgb, var(--bc-accent) 18%, var(--bc-bg));
137
+ :host(.bc-auto) {${DARK}
129
138
  }
130
139
  }
131
140
 
132
141
  * { box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif; }
142
+
143
+ /* ---------- the orb ----------
144
+ The assistant's face, on the launcher and at the top of every screen: three
145
+ blurred colour blobs turning slowly under a highlight and grain. Hue from
146
+ --bc-o1..3. Sized by whoever places it. */
147
+ .orb {
148
+ position: relative; display: block; flex: none; aspect-ratio: 1; border-radius: 50%;
149
+ background: var(--bc-o2); overflow: hidden; isolation: isolate;
150
+ box-shadow: inset -3px -5px 10px rgba(0,0,0,.22), inset 2px 3px 7px rgba(255,255,255,.35);
151
+ }
152
+ .orb::before {
153
+ content: ''; position: absolute; inset: -20%; border-radius: 50%; z-index: -1;
154
+ background:
155
+ radial-gradient(circle at 70% 72%, var(--bc-o3) 0%, transparent 44%),
156
+ radial-gradient(circle at 26% 66%, var(--bc-o1) 0%, transparent 48%),
157
+ radial-gradient(circle at 58% 30%, var(--bc-o2) 0%, transparent 62%);
158
+ filter: blur(3px);
159
+ animation: bc-orb-turn 18s linear infinite;
160
+ }
161
+ .orb::after {
162
+ content: ''; position: absolute; inset: 0; border-radius: 50%;
163
+ background: radial-gradient(circle at 34% 26%, rgba(255,255,255,.85) 0 5%, rgba(255,255,255,0) 30%), var(--bc-grain);
164
+ mix-blend-mode: soft-light;
165
+ }
166
+ @keyframes bc-orb-turn { to { transform: rotate(360deg); } }
133
167
  svg { width: 1em; height: 1em; display: block; }
134
168
  /* The hidden attribute has to actually hide. Nearly every element in here is given a
135
169
  display by a rule below, and any of them beats the attribute's own UA style
@@ -160,65 +194,50 @@ button:focus-visible, a:focus-visible, select:focus-visible, [tabindex]:focus-vi
160
194
  @keyframes bc-launcher-in { from { opacity: 0; transform: translateY(12px) scale(.86) } to { opacity: 1; transform: none } }
161
195
  @keyframes bc-item-in { from { opacity: 0; transform: translateY(8px) scale(.96) } to { opacity: 1; transform: none } }
162
196
 
163
- /* ---------- dock: theme switch stacked above the launcher ---------- */
197
+ /* ---------- dock: the launcher, and what is said beside it ---------- */
164
198
  .dock {
165
199
  position: fixed; z-index: 2147483000;
166
200
  display: flex; flex-direction: column; gap: 10px;
167
201
  }
168
202
  .dock[hidden] { display: none; }
169
- /* While the panel is open the switch rises above the scrim so it stays usable
170
- — the panel shares the shadow root, so a change re-themes the open form. */
203
+ /* While the panel is open the launcher rises above the scrim, so the button
204
+ that opened it is the button that closes it. */
171
205
  .dock.over { z-index: 2147483002; }
172
206
  .dock.at-right { align-items: flex-end; }
173
207
  .dock.at-left { align-items: flex-start; }
174
- /* Anchored to the top, "above the launcher" would be off-screen, so the
175
- switch goes under it and the launcher stays the outermost thing. */
208
+ /* Anchored to the top, "above the launcher" would be off-screen, so cards go
209
+ under it and the launcher stays the outermost thing. */
176
210
  .dock.at-top { flex-direction: column-reverse; }
177
211
 
178
- .themer {
179
- display: inline-flex; align-items: center; gap: 2px;
180
- padding: 3px; border-radius: 999px;
181
- background: var(--bc-elevated); border: 1px solid var(--bc-border);
182
- box-shadow: 0 6px 18px rgba(10,12,16,.14), 0 1px 3px rgba(10,12,16,.08);
183
- animation: bc-launcher-in 420ms var(--bc-ease) both;
184
- }
185
- .themer button {
186
- display: grid; place-items: center;
187
- width: 28px; height: 28px; padding: 0;
188
- border: none; border-radius: 999px; background: transparent;
189
- color: var(--bc-muted); cursor: pointer;
190
- transition: background var(--bc-dur) var(--bc-ease), color var(--bc-dur) var(--bc-ease), transform 140ms var(--bc-ease);
191
- }
192
- .themer button svg { font-size: 15px; }
193
- .themer button:hover { background: var(--bc-subtle); color: var(--bc-fg); }
194
- .themer button:active { transform: scale(.92); }
195
- .themer button[aria-pressed="true"] { background: var(--bc-accent); color: var(--bc-on-accent); }
196
- .themer button[aria-pressed="true"]:hover { background: var(--bc-accent); filter: brightness(1.06); }
197
-
198
- /* ---------- launcher ---------- */
212
+ /* ---------- launcher ----------
213
+ A pill with the assistant's orb in it: ink by default, the host's colour when
214
+ they set one. The shadow is neutral, never tinted, so a bright brand colour
215
+ does not glow on somebody's checkout. */
199
216
  .launcher {
200
217
  position: relative;
201
- display: inline-flex; align-items: center; gap: 9px;
202
- height: 48px; padding: 0 20px 0 16px; border: none; border-radius: 999px;
218
+ display: inline-flex; align-items: center; gap: 10px;
219
+ height: 46px; padding: 0 18px 0 7px; border: none; border-radius: 999px;
203
220
  background: var(--bc-accent); color: var(--bc-on-accent);
204
- font-size: var(--bc-fs-md); font-weight: 620; line-height: 1; letter-spacing: -.01em;
221
+ font-size: var(--bc-fs-md); font-weight: 560; line-height: 1; letter-spacing: -.01em;
205
222
  cursor: pointer;
206
- box-shadow: 0 10px 28px color-mix(in srgb, var(--bc-accent) 34%, rgba(10,12,16,.2)), 0 2px 6px rgba(10,12,16,.16);
207
- transition: transform var(--bc-dur) var(--bc-ease), box-shadow var(--bc-dur) var(--bc-ease), filter 160ms linear, padding var(--bc-dur) var(--bc-ease);
223
+ box-shadow: 0 1px 2px rgba(0,0,0,.18), 0 10px 26px -8px rgba(0,0,0,.36);
224
+ transition: transform var(--bc-dur) var(--bc-ease), box-shadow var(--bc-dur) var(--bc-ease), filter 160ms linear;
208
225
  animation: bc-launcher-in 420ms var(--bc-ease) both;
209
226
  }
210
- /* Two glyphs in one box: the bubble when closed, a chevron when open, turned
227
+ /* Two faces in one circle: the orb when closed, a chevron when open, turned
211
228
  into each other so the button reads as the panel's own handle. */
212
- .launcher .ico { position: relative; width: 20px; height: 20px; flex: none; }
229
+ .launcher .ico { position: relative; width: 32px; height: 32px; flex: none; }
213
230
  .launcher .ico > span {
214
- position: absolute; inset: 0;
231
+ position: absolute; inset: 0; display: grid; place-items: center; border-radius: 50%;
215
232
  transition: transform 320ms var(--bc-ease), opacity 200ms ease;
216
233
  }
217
- .launcher .ico svg { width: 20px; height: 20px; }
234
+ .launcher .ico .orb { width: 32px; }
235
+ .launcher .ico .when-open { background: color-mix(in srgb, var(--bc-on-accent) 14%, transparent); }
236
+ .launcher .ico svg { width: 18px; height: 18px; }
218
237
  .launcher .ico .when-open { opacity: 0; transform: rotate(-90deg) scale(.6); }
219
238
  .launcher[aria-expanded="true"] .ico .when-closed { opacity: 0; transform: rotate(90deg) scale(.6); }
220
239
  .launcher[aria-expanded="true"] .ico .when-open { opacity: 1; transform: none; }
221
- .launcher:hover { transform: translateY(-2px); filter: brightness(1.06); }
240
+ .launcher:hover { transform: translateY(-1px); box-shadow: 0 1px 2px rgba(0,0,0,.18), 0 14px 30px -8px rgba(0,0,0,.42); }
222
241
  .launcher:active { transform: translateY(0) scale(.97); transition-duration: 90ms; }
223
242
  .launcher:focus-visible { outline-offset: 3px; }
224
243
  .launcher[hidden] { display: none; }
@@ -241,11 +260,11 @@ button:focus-visible, a:focus-visible, select:focus-visible, [tabindex]:focus-vi
241
260
  In this sheet because the card beside the button exists before the panel's
242
261
  does. The tone is a colour and an icon; the site's accent is the info one. */
243
262
  .notice {
244
- --tone: var(--bc-accent);
263
+ --tone: var(--bc-info);
245
264
  display: flex; align-items: flex-start; gap: 10px;
246
- padding: 12px 12px 12px 14px; border-radius: var(--bc-r-md);
247
- background: color-mix(in srgb, var(--tone) 7%, var(--bc-elevated));
248
- border: 1px solid color-mix(in srgb, var(--tone) 28%, var(--bc-border));
265
+ padding: 11px 10px 11px 12px; border-radius: var(--bc-r-md);
266
+ background: var(--bc-subtle);
267
+ border: 1px solid var(--bc-border);
249
268
  color: var(--bc-fg);
250
269
  font-size: var(--bc-fs-sm); line-height: 1.45; text-align: left;
251
270
  animation: bc-item-in 240ms var(--bc-ease) both;
@@ -253,33 +272,48 @@ button:focus-visible, a:focus-visible, select:focus-visible, [tabindex]:focus-vi
253
272
  .notice.tone-success { --tone: var(--bc-ok); }
254
273
  .notice.tone-warning { --tone: var(--bc-warn); }
255
274
  .notice.tone-critical { --tone: var(--bc-danger); }
256
- .notice-ico { flex: none; color: var(--tone); font-size: 16px; margin-top: 1px; }
275
+ /* The tone is the icon's colour and a wash behind it — enough to tell a delay
276
+ from a fix at a glance, without a coloured card shouting over the form. */
277
+ .notice-ico {
278
+ flex: none; display: grid; place-items: center; width: 22px; height: 22px; margin-top: -1px;
279
+ border-radius: 7px; font-size: 14px;
280
+ color: var(--tone); background: color-mix(in srgb, var(--tone) 13%, transparent);
281
+ }
257
282
  .notice-text { flex: 1; min-width: 0; overflow-wrap: anywhere; }
258
- .notice-text strong { display: block; font-weight: 650; letter-spacing: -.005em; }
283
+ .notice-text strong { display: block; font-weight: 600; letter-spacing: -.005em; }
259
284
  .notice-text p { margin: 2px 0 0; color: var(--bc-muted); white-space: pre-line; }
260
285
  .notice-cta {
261
- display: inline-block; margin-top: 6px;
262
- color: var(--tone); font-weight: 620; text-decoration: none;
286
+ display: inline-flex; align-items: center; gap: 4px; margin-top: 6px;
287
+ color: var(--bc-fg); font-weight: 600; text-decoration: underline;
288
+ text-decoration-color: color-mix(in srgb, var(--bc-fg) 30%, transparent); text-underline-offset: 3px;
263
289
  }
264
- .notice-cta:hover { text-decoration: underline; text-underline-offset: 2px; }
290
+ .notice-cta:hover { text-decoration-color: currentColor; }
265
291
  .notice-x {
266
292
  flex: none; display: grid; place-items: center;
267
- width: 26px; height: 26px; margin: -4px -4px 0 0; padding: 0;
293
+ width: 24px; height: 24px; margin: -2px -2px 0 0; padding: 0;
268
294
  border: 0; border-radius: 999px; background: transparent;
269
- color: var(--bc-muted); cursor: pointer;
295
+ color: var(--bc-faint); cursor: pointer;
270
296
  }
271
- .notice-x:hover { background: color-mix(in srgb, var(--tone) 12%, transparent); color: var(--bc-fg); }
272
- .notice-x svg { font-size: 14px; }
297
+ .notice-x:hover { background: color-mix(in srgb, var(--bc-fg) 7%, transparent); color: var(--bc-fg); }
298
+ .notice-x svg { font-size: 13px; }
273
299
  /* Beside the button it floats over somebody else's page: opaque, lifted, and
274
300
  never taller than a glance. The whole text is in the panel. */
275
301
  .notice.at-launcher {
276
302
  width: min(320px, calc(100vw - 40px));
277
- box-shadow: 0 10px 28px rgba(10,12,16,.14), 0 1px 3px rgba(10,12,16,.08);
303
+ background: var(--bc-elevated); border-color: transparent;
304
+ box-shadow: var(--bc-shadow);
305
+ padding: 13px 12px 13px 13px;
278
306
  }
279
307
  .notice.at-launcher .notice-text p {
280
308
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden;
281
309
  }
282
- .notice.at-panel { margin-bottom: 10px; }
310
+ /* In the panel they are a stack of short lines above the form, not a wall:
311
+ the body is held to two lines, and the whole of it was beside the button. */
312
+ .notice.at-panel { margin-bottom: 8px; padding: 9px 8px 9px 10px; }
313
+ .notice.at-panel .notice-text p {
314
+ display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden;
315
+ }
316
+ .notice.at-panel .notice-cta { margin-top: 3px; }
283
317
 
284
318
  /* The dock sits in a corner of a page the widget does not own. On a notched
285
319
  phone that corner is under the home indicator, and in landscape it is under
@@ -299,25 +333,12 @@ button:focus-visible, a:focus-visible, select:focus-visible, [tabindex]:focus-vi
299
333
  .dock.over { display: none; }
300
334
  }
301
335
  @media (pointer: coarse) {
302
- /* The theme switch's buttons are small glyphs whose boxes cannot grow without
303
- moving the launcher under them, so each keeps its size and gets an
304
- invisible target drawn around it instead. */
305
- .themer button { position: relative; }
306
- .themer button::after {
307
- content: '';
308
- position: absolute;
309
- inset: -8px;
310
- }
311
- /* The themer's three buttons sit side by side, so their expanders would
312
- overlap each other without a gap to grow into. */
313
- .themer { gap: 4px; }
314
336
  .notice-x { width: 36px; height: 36px; margin: -9px -8px -6px 0; }
315
337
  }
316
338
  /* Hover is a lie on a touchscreen: it latches on tap and stays. The launcher's
317
339
  lift in particular was left applied after the panel closed. */
318
340
  @media (hover: none) {
319
341
  .launcher:hover { transform: none; filter: none; }
320
- .themer button:hover { transform: none; }
321
342
  }
322
343
 
323
344
  /* ---------- while the panel's code is on its way ----------
@@ -356,7 +377,7 @@ export function mountWidget(client) {
356
377
 
357
378
  const root = host.attachShadow({ mode: 'open' });
358
379
  const style = document.createElement('style');
359
- style.textContent = css(client.config.accent || '#4F46E5');
380
+ style.textContent = css(client.config.accent || null);
360
381
  root.appendChild(style);
361
382
 
362
383
  const position = POSITIONS[client.config.widgetPosition] ? client.config.widgetPosition : 'bottom-right';
@@ -365,30 +386,27 @@ export function mountWidget(client) {
365
386
  + (position.startsWith('top') ? ' at-top' : '');
366
387
  dock.style.cssText = POSITIONS[position];
367
388
 
368
- const themer = document.createElement('div');
369
- themer.className = 'themer';
370
- themer.setAttribute('role', 'group');
371
- themer.setAttribute('aria-label', 'Report widget theme');
372
- themer.innerHTML = THEMES.map((t) => `<button type="button" data-theme="${t.value}" `
373
- + `title="${t.label}" aria-label="${t.label}" aria-pressed="${t.value === theme}">${t.icon}</button>`).join('');
374
-
375
- themer.addEventListener('click', (e) => {
376
- const btn = e.target.closest('button[data-theme]');
377
- if (!btn || btn.dataset.theme === theme) return;
378
- theme = btn.dataset.theme;
379
- // The panel lives in the same shadow root, so an open form re-themes with
380
- // it rather than waiting for the next open.
381
- host.className = themeClass(theme);
382
- writeStoredTheme(theme);
383
- themer.querySelectorAll('button').forEach((b) => {
384
- b.setAttribute('aria-pressed', String(b.dataset.theme === theme));
385
- });
386
- });
389
+ // The theme is chosen inside the panel, from its header (panel/render.js),
390
+ // not from a switch parked beside the button on every page of the host's
391
+ // site. Light, dark, then the system's own choice, and round again.
392
+ const themeControls = {
393
+ current: () => theme,
394
+ label: () => THEMES.find((t) => t.value === theme).label,
395
+ cycle() {
396
+ const next = THEMES[(THEMES.findIndex((t) => t.value === theme) + 1) % THEMES.length];
397
+ theme = next.value;
398
+ // The panel lives in the same shadow root, so an open form re-themes with
399
+ // it rather than waiting for the next open.
400
+ host.className = themeClass(theme);
401
+ writeStoredTheme(theme);
402
+ return next;
403
+ },
404
+ };
387
405
 
388
406
  const launcher = document.createElement('button');
389
407
  launcher.className = 'launcher';
390
408
  launcher.type = 'button';
391
- launcher.innerHTML = `<span class="ico" aria-hidden="true"><span class="when-closed">${ICONS.chat}</span>`
409
+ launcher.innerHTML = `<span class="ico" aria-hidden="true"><span class="when-closed"><span class="orb"></span></span>`
392
410
  + `<span class="when-open">${ICONS.chevronDown}</span></span><span>Report a bug</span>`;
393
411
  launcher.setAttribute('aria-haspopup', 'dialog');
394
412
  launcher.setAttribute('aria-expanded', 'false');
@@ -433,6 +451,7 @@ export function mountWidget(client) {
433
451
  dock,
434
452
  client,
435
453
  preloadCall,
454
+ themeControls,
436
455
  onClose: () => {
437
456
  open = null;
438
457
  dock.classList.remove('over');
@@ -484,8 +503,8 @@ export function mountWidget(client) {
484
503
  launcher.focus();
485
504
  hideLoadFailure();
486
505
  });
487
- // First in the dock, so it sits furthest from the corner: above the theme
488
- // switch in a bottom dock, below it in a top one.
506
+ // First in the dock, so it sits furthest from the corner: above the
507
+ // launcher in a bottom dock, below it in a top one.
489
508
  dock.prepend(failure);
490
509
  }
491
510
 
@@ -532,7 +551,7 @@ export function mountWidget(client) {
532
551
  .catch(() => { /* no card is the answer to every failure */ });
533
552
  });
534
553
 
535
- dock.append(themer, launcher);
554
+ dock.append(launcher);
536
555
  root.appendChild(dock);
537
556
 
538
557
  document.body.appendChild(host);