@sphoro/bugcatch-web-sdk 0.14.4 → 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.
package/src/widget.js CHANGED
@@ -46,7 +46,18 @@ const ICONS = {
46
46
  retry: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 12a8 8 0 1 1-2.6-5.9M20 4v4.5h-4.5"/></svg>',
47
47
  sun: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4.2"/><path d="M12 2.6v2.2M12 19.2v2.2M2.6 12h2.2M19.2 12h2.2M5.4 5.4l1.6 1.6M17 17l1.6 1.6M18.6 5.4L17 7M7 17l-1.6 1.6"/></svg>',
48
48
  moon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13.4A8.2 8.2 0 1 1 10.6 4a6.6 6.6 0 0 0 9.4 9.4z"/></svg>',
49
- auto: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><rect x="2.8" y="4.4" width="18.4" height="12.6" rx="2.2"/><path d="M8.6 20.2h6.8"/><path d="M12 4.4v12.6"/><path d="M12 7.2a3.6 3.6 0 0 1 0 7.2z" fill="currentColor" stroke="none"/></svg>'};
49
+ auto: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><rect x="2.8" y="4.4" width="18.4" height="12.6" rx="2.2"/><path d="M8.6 20.2h6.8"/><path d="M12 4.4v12.6"/><path d="M12 7.2a3.6 3.6 0 0 1 0 7.2z" fill="currentColor" stroke="none"/></svg>',
50
+ // The assistant's mark: a four-point spark, drawn wherever it speaks.
51
+ sparkle: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.5c.5 4.6 2.9 7 7.5 7.5v.1c-4.6.5-7 2.9-7.5 7.5h-.1c-.5-4.6-2.9-7-7.5-7.5V10c4.6-.5 7-2.9 7.5-7.5z"/><path d="M18.6 15.2c.2 1.9 1.2 2.9 3.1 3.1v.1c-1.9.2-2.9 1.2-3.1 3.1h-.1c-.2-1.9-1.2-2.9-3.1-3.1v-.1c1.9-.2 2.9-1.2 3.1-3.1z" opacity=".7"/></svg>',
52
+ chat: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M4 11.5a7.5 7 0 0 1 7.5-7h1a7.5 7 0 0 1 0 14H9l-4.2 2.6a.5.5 0 0 1-.76-.5l.5-3.1A7 7 0 0 1 4 11.5z"/><path d="M8.5 11.5h.01M12 11.5h.01M15.5 11.5h.01" stroke-width="2.6"/></svg>',
53
+ chevronDown: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9.5l6 6 6-6"/></svg>',
54
+ chevronRight: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 6l6 6-6 6"/></svg>',
55
+ alert: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5.2M12 16.3h.01"/></svg>',
56
+ inbox: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 13.5l2.6-7.2A1.5 1.5 0 0 1 7.5 5.3h9a1.5 1.5 0 0 1 1.4 1L20.5 13.5V18a1.5 1.5 0 0 1-1.5 1.5H5A1.5 1.5 0 0 1 3.5 18z"/><path d="M3.5 13.5h4.8l1.2 2.2h5l1.2-2.2h4.8"/></svg>',
57
+ };
58
+
59
+ /* The assistant's avatar, wherever it appears. */
60
+ const AVATAR = `<span class="avatar" aria-hidden="true">${ICONS.sparkle}</span>`;
50
61
 
51
62
  /* The sphoro lockup, as outlines rather than text.
52
63
  *
@@ -97,10 +108,10 @@ const css = (accent) => `
97
108
  --bc-bg: #fff;
98
109
  --bc-elevated: #fff;
99
110
  --bc-fg: #14161c;
100
- --bc-muted: #6b7280;
111
+ --bc-muted: #5f6673;
101
112
  --bc-faint: #9aa1ad;
102
- --bc-border: #e3e6ec;
103
- --bc-subtle: #f5f6f8;
113
+ --bc-border: #e6e8ee;
114
+ --bc-subtle: #f4f5f8;
104
115
  --bc-raised: #fff;
105
116
  --bc-danger: #dc2626;
106
117
  --bc-ok: #059669;
@@ -110,36 +121,56 @@ const css = (accent) => `
110
121
  identifies itself without out-shouting the host's page. */
111
122
  --bc-brand-ink: #4b5563;
112
123
  --bc-brand-mark: #5647ee;
113
- --bc-shadow: 0 24px 60px rgba(10,12,16,.28), 0 2px 8px rgba(10,12,16,.08);
124
+ --bc-shadow: 0 24px 64px rgba(10,12,16,.22), 0 4px 14px rgba(10,12,16,.08);
125
+ --bc-shadow-sm: 0 1px 2px rgba(10,12,16,.06), 0 1px 1px rgba(10,12,16,.04);
114
126
  --bc-dur: 240ms;
115
127
  --bc-ease: cubic-bezier(.22,1,.36,1);
128
+ /* The system everything below is drawn from. Derived tokens are written in
129
+ terms of the ones above, so a host that overrides --bc-accent or the
130
+ palette gets a coherent result without touching these. */
131
+ --bc-accent-soft: color-mix(in srgb, var(--bc-accent) 10%, var(--bc-bg));
132
+ --bc-accent-line: color-mix(in srgb, var(--bc-accent) 32%, var(--bc-border));
133
+ --bc-ring: 0 0 0 3px color-mix(in srgb, var(--bc-accent) 22%, transparent);
134
+ --bc-r-sm: 8px;
135
+ --bc-r-md: 12px;
136
+ --bc-r-lg: 18px;
137
+ --bc-r-xl: 22px;
138
+ --bc-fs-xs: 11.5px;
139
+ --bc-fs-sm: 12.5px;
140
+ --bc-fs-md: 14px;
141
+ --bc-fs-lg: 16px;
142
+ --bc-pad: 18px;
116
143
  }
117
144
  :host(.bc-dark) {
118
145
  --bc-bg: #14161c;
119
146
  --bc-elevated: #1c1f27;
120
147
  --bc-fg: #eef0f4;
121
- --bc-muted: #9aa1ad;
122
- --bc-faint: #6b7280;
123
- --bc-border: #2b2f39;
124
- --bc-subtle: #21242c;
148
+ --bc-muted: #a3aab6;
149
+ --bc-faint: #6f7682;
150
+ --bc-border: #2a2e38;
151
+ --bc-subtle: #1f222a;
125
152
  --bc-raised: #363d4b;
126
153
  --bc-brand-ink: #9aa1ad;
127
154
  --bc-brand-mark: #9b8fff;
128
- --bc-shadow: 0 24px 60px rgba(0,0,0,.6), 0 2px 8px rgba(0,0,0,.4);
155
+ --bc-shadow: 0 24px 64px rgba(0,0,0,.6), 0 4px 14px rgba(0,0,0,.4);
156
+ --bc-shadow-sm: 0 1px 2px rgba(0,0,0,.4);
157
+ --bc-accent-soft: color-mix(in srgb, var(--bc-accent) 18%, var(--bc-bg));
129
158
  }
130
159
  @media (prefers-color-scheme: dark) {
131
160
  :host(.bc-auto) {
132
161
  --bc-bg: #14161c;
133
162
  --bc-elevated: #1c1f27;
134
163
  --bc-fg: #eef0f4;
135
- --bc-muted: #9aa1ad;
136
- --bc-faint: #6b7280;
137
- --bc-border: #2b2f39;
138
- --bc-subtle: #21242c;
164
+ --bc-muted: #a3aab6;
165
+ --bc-faint: #6f7682;
166
+ --bc-border: #2a2e38;
167
+ --bc-subtle: #1f222a;
139
168
  --bc-raised: #363d4b;
140
169
  --bc-brand-ink: #9aa1ad;
141
170
  --bc-brand-mark: #9b8fff;
142
- --bc-shadow: 0 24px 60px rgba(0,0,0,.6), 0 2px 8px rgba(0,0,0,.4);
171
+ --bc-shadow: 0 24px 64px rgba(0,0,0,.6), 0 4px 14px rgba(0,0,0,.4);
172
+ --bc-shadow-sm: 0 1px 2px rgba(0,0,0,.4);
173
+ --bc-accent-soft: color-mix(in srgb, var(--bc-accent) 18%, var(--bc-bg));
143
174
  }
144
175
  }
145
176
 
@@ -150,6 +181,16 @@ svg { width: 1em; height: 1em; display: block; }
150
181
  — which is why .dock and .launcher each had to say so themselves. Said once,
151
182
  for all of them, so the next element hidden from JavaScript works. */
152
183
  [hidden] { display: none !important; }
184
+ .sr-only {
185
+ position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
186
+ overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
187
+ }
188
+
189
+ /* One focus ring for everything clickable, so keyboard users never have to
190
+ guess where they are. Fields draw their own ring (a glow, not an outline). */
191
+ button:focus-visible, a:focus-visible, select:focus-visible, [tabindex]:focus-visible {
192
+ outline: 2px solid var(--bc-accent); outline-offset: 2px;
193
+ }
153
194
 
154
195
  /* Motion is decoration here — every animation below is an entrance or a state
155
196
  hint, so switching them off costs nothing but the polish. */
@@ -163,14 +204,19 @@ svg { width: 1em; height: 1em; display: block; }
163
204
 
164
205
  @keyframes bc-fade-in { from { opacity: 0 } to { opacity: 1 } }
165
206
  @keyframes bc-fade-out { to { opacity: 0 } }
166
- @keyframes bc-panel-in { from { opacity: 0; transform: translateY(16px) scale(.97) } to { opacity: 1; transform: none } }
167
- @keyframes bc-panel-out { to { opacity: 0; transform: translateY(8px) scale(.985) } }
207
+ @keyframes bc-panel-in { from { opacity: 0; transform: translateY(14px) scale(.965) } to { opacity: 1; transform: none } }
208
+ @keyframes bc-panel-out { to { opacity: 0; transform: translateY(10px) scale(.975) } }
209
+ @keyframes bc-sheet-in { from { transform: translateY(100%) } to { transform: none } }
210
+ @keyframes bc-sheet-out { to { transform: translateY(100%) } }
168
211
  @keyframes bc-launcher-in { from { opacity: 0; transform: translateY(12px) scale(.86) } to { opacity: 1; transform: none } }
169
- @keyframes bc-item-in { from { opacity: 0; transform: translateY(8px) scale(.92) } to { opacity: 1; transform: none } }
212
+ @keyframes bc-item-in { from { opacity: 0; transform: translateY(8px) scale(.96) } to { opacity: 1; transform: none } }
213
+ @keyframes bc-rise { from { opacity: 0; transform: translateY(6px) } to { opacity: 1; transform: none } }
170
214
  @keyframes bc-pulse { 0%, 100% { opacity: 1; transform: scale(1) } 50% { opacity: .3; transform: scale(.78) } }
171
215
  @keyframes bc-spin { to { transform: rotate(360deg) } }
172
216
  @keyframes bc-pop { 0% { transform: scale(.5); opacity: 0 } 65% { transform: scale(1.1) } 100% { transform: scale(1); opacity: 1 } }
173
217
  @keyframes bc-nudge { 25% { transform: translateX(-3px) } 75% { transform: translateX(3px) } }
218
+ @keyframes bc-typing { 0%, 60%, 100% { transform: translateY(0); opacity: .45 } 30% { transform: translateY(-3px); opacity: 1 } }
219
+ @keyframes bc-shimmer { from { background-position: 200% 0 } to { background-position: -200% 0 } }
174
220
 
175
221
  /* ---------- dock: theme switch stacked above the launcher ---------- */
176
222
  .dock {
@@ -191,7 +237,7 @@ svg { width: 1em; height: 1em; display: block; }
191
237
  display: inline-flex; align-items: center; gap: 2px;
192
238
  padding: 3px; border-radius: 999px;
193
239
  background: var(--bc-elevated); border: 1px solid var(--bc-border);
194
- box-shadow: 0 6px 18px rgba(10,12,16,.16), 0 1px 3px rgba(10,12,16,.10);
240
+ box-shadow: 0 6px 18px rgba(10,12,16,.14), 0 1px 3px rgba(10,12,16,.08);
195
241
  animation: bc-launcher-in 420ms var(--bc-ease) both;
196
242
  }
197
243
  .themer button {
@@ -206,88 +252,152 @@ svg { width: 1em; height: 1em; display: block; }
206
252
  .themer button:active { transform: scale(.92); }
207
253
  .themer button[aria-pressed="true"] { background: var(--bc-accent); color: var(--bc-on-accent); }
208
254
  .themer button[aria-pressed="true"]:hover { background: var(--bc-accent); filter: brightness(1.06); }
209
- .themer button:focus-visible { outline: 2px solid var(--bc-accent); outline-offset: 2px; }
210
255
 
211
256
  /* ---------- launcher ---------- */
212
257
  .launcher {
213
- display: inline-flex; align-items: center; gap: 8px;
214
- padding: 11px 17px; border: none; border-radius: 999px;
258
+ position: relative;
259
+ display: inline-flex; align-items: center; gap: 9px;
260
+ height: 48px; padding: 0 20px 0 16px; border: none; border-radius: 999px;
215
261
  background: var(--bc-accent); color: var(--bc-on-accent);
216
- font-size: 14px; font-weight: 600; line-height: 1; letter-spacing: -.01em;
262
+ font-size: var(--bc-fs-md); font-weight: 620; line-height: 1; letter-spacing: -.01em;
217
263
  cursor: pointer;
218
- box-shadow: 0 6px 18px rgba(10,12,16,.22), 0 1px 3px rgba(10,12,16,.16);
219
- transition: transform var(--bc-dur) var(--bc-ease), box-shadow var(--bc-dur) var(--bc-ease), filter 160ms linear;
264
+ 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);
265
+ 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);
220
266
  animation: bc-launcher-in 420ms var(--bc-ease) both;
221
267
  }
222
- .launcher svg { font-size: 17px; }
223
- .launcher:hover { transform: translateY(-2px); box-shadow: 0 12px 28px rgba(10,12,16,.26), 0 2px 6px rgba(10,12,16,.18); filter: brightness(1.06); }
268
+ /* Two glyphs in one box: the bubble when closed, a chevron when open, turned
269
+ into each other so the button reads as the panel's own handle. */
270
+ .launcher .ico { position: relative; width: 20px; height: 20px; flex: none; }
271
+ .launcher .ico > span {
272
+ position: absolute; inset: 0;
273
+ transition: transform 320ms var(--bc-ease), opacity 200ms ease;
274
+ }
275
+ .launcher .ico svg { width: 20px; height: 20px; }
276
+ .launcher .ico .when-open { opacity: 0; transform: rotate(-90deg) scale(.6); }
277
+ .launcher[aria-expanded="true"] .ico .when-closed { opacity: 0; transform: rotate(90deg) scale(.6); }
278
+ .launcher[aria-expanded="true"] .ico .when-open { opacity: 1; transform: none; }
279
+ .launcher:hover { transform: translateY(-2px); filter: brightness(1.06); }
224
280
  .launcher:active { transform: translateY(0) scale(.97); transition-duration: 90ms; }
225
- .launcher:focus-visible { outline: 2px solid var(--bc-accent); outline-offset: 3px; }
281
+ .launcher:focus-visible { outline-offset: 3px; }
226
282
  .launcher[hidden] { display: none; }
227
283
 
228
284
  /* ---------- scrim + panel ---------- */
229
285
  .scrim {
230
286
  position: fixed; inset: 0; z-index: 2147483001;
231
- background: rgba(10,12,16,.5);
287
+ background: rgba(10,12,16,.32);
232
288
  animation: bc-fade-in 200ms ease both;
233
289
  }
234
- .scrim.closing { animation: bc-fade-out 150ms ease both; }
290
+ .scrim.closing { animation: bc-fade-out 170ms ease both; }
235
291
 
236
292
  /* The panel belongs to the launcher, so it opens out of that corner rather
237
293
  than in the middle of the screen. --bc-dock-h is measured at open time
238
- because the dock's height depends on whether the launcher is showing. */
294
+ because the dock's height depends on whether the launcher is showing.
295
+ A column: the header and footer stay put and only the middle scrolls. */
239
296
  .panel {
240
297
  position: fixed;
241
- width: calc(100vw - 40px); max-width: 440px;
298
+ display: flex; flex-direction: column;
299
+ width: calc(100vw - 40px); max-width: 420px;
242
300
  max-height: calc(100vh - var(--bc-dock-h, 84px) - 52px);
243
- overflow-y: auto; overscroll-behavior: contain;
301
+ overflow: hidden;
244
302
  background: var(--bc-bg); color: var(--bc-fg);
245
- border-radius: 16px; box-shadow: var(--bc-shadow);
246
- animation: bc-panel-in 320ms var(--bc-ease) both;
247
- }
248
- .scrim.at-right .panel { right: 20px; }
249
- .scrim.at-left .panel { left: 20px; }
303
+ border-radius: var(--bc-r-xl); box-shadow: var(--bc-shadow);
304
+ border: 1px solid color-mix(in srgb, var(--bc-border) 70%, transparent);
305
+ animation: bc-panel-in 340ms var(--bc-ease) both;
306
+ }
307
+ /* A conversation wants a height of its own, so the thread fills the panel
308
+ rather than shrinking to its first two messages. */
309
+ .panel.is-chat { height: 640px; }
310
+ .scrim.at-right .panel { right: 20px; transform-origin: bottom right; }
311
+ .scrim.at-left .panel { left: 20px; transform-origin: bottom left; }
250
312
  .scrim.at-bottom .panel { bottom: calc(20px + var(--bc-dock-h, 84px) + 12px); }
251
- .scrim.at-top .panel { top: calc(20px + var(--bc-dock-h, 84px) + 12px); }
252
- .scrim.closing .panel { animation: bc-panel-out 150ms ease both; }
313
+ .scrim.at-top .panel { top: calc(20px + var(--bc-dock-h, 84px) + 12px); transform-origin: top right; }
314
+ .scrim.closing .panel { animation: bc-panel-out 170ms ease both; }
253
315
 
254
- .head { display: flex; align-items: flex-start; gap: 11px; padding: 18px 18px 0; }
255
- .head .mark {
256
- flex: none; display: grid; place-items: center;
257
- width: 32px; height: 32px; border-radius: 9px;
258
- background: color-mix(in srgb, var(--bc-accent) 13%, transparent);
259
- color: var(--bc-accent); font-size: 18px;
316
+ /* ---------- header ---------- */
317
+ .head {
318
+ flex: none; position: relative;
319
+ display: flex; align-items: center; gap: 12px;
320
+ padding: 16px 14px 14px var(--bc-pad);
321
+ background: linear-gradient(180deg, var(--bc-accent-soft), var(--bc-bg));
322
+ border-bottom: 1px solid var(--bc-border);
323
+ }
324
+ .head .mark, .avatar {
325
+ flex: none; display: grid; place-items: center; position: relative;
326
+ width: 38px; height: 38px; border-radius: 50%;
327
+ background: linear-gradient(140deg, var(--bc-accent), color-mix(in srgb, var(--bc-accent) 60%, #fff));
328
+ color: var(--bc-on-accent); font-size: 19px;
329
+ box-shadow: 0 4px 12px color-mix(in srgb, var(--bc-accent) 30%, transparent);
330
+ }
331
+ /* The little "online" dot says someone — or something — is on the other end. */
332
+ .head .mark::after {
333
+ content: ''; position: absolute; right: -1px; bottom: -1px;
334
+ width: 11px; height: 11px; border-radius: 50%;
335
+ background: #22c55e; border: 2px solid var(--bc-bg);
260
336
  }
261
337
  .head .titles { flex: 1; min-width: 0; }
262
- .head h2 { margin: 1px 0 3px; font-size: 16px; font-weight: 650; letter-spacing: -.015em; }
263
- .head p { margin: 0; font-size: 12.5px; line-height: 1.45; color: var(--bc-muted); }
338
+ .head h2 {
339
+ margin: 0 0 2px; font-size: var(--bc-fs-lg); font-weight: 680; letter-spacing: -.02em; line-height: 1.25;
340
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
341
+ }
342
+ .head p { margin: 0; font-size: var(--bc-fs-sm); line-height: 1.45; color: var(--bc-muted); }
264
343
  .icon-btn {
265
344
  flex: none; display: grid; place-items: center;
266
- width: 28px; height: 28px; padding: 0; font-size: 15px;
267
- border: none; border-radius: 8px; background: none; color: var(--bc-faint);
268
- cursor: pointer; transition: background 160ms, color 160ms;
345
+ width: 32px; height: 32px; padding: 0; font-size: 16px;
346
+ border: none; border-radius: 10px; background: none; color: var(--bc-muted);
347
+ cursor: pointer; transition: background 160ms, color 160ms, transform 160ms var(--bc-ease);
269
348
  }
270
- .icon-btn:hover { background: var(--bc-subtle); color: var(--bc-fg); }
271
- .icon-btn:focus-visible { outline: 2px solid var(--bc-accent); outline-offset: 1px; }
272
-
273
- .body { padding: 16px 18px 0; }
349
+ .icon-btn:hover { background: color-mix(in srgb, var(--bc-fg) 7%, transparent); color: var(--bc-fg); }
350
+ .icon-btn:active { transform: scale(.92); }
351
+ .icon-btn.back { margin: 0 -6px 0 -8px; }
274
352
 
353
+ .body {
354
+ flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
355
+ padding: 16px var(--bc-pad) 6px;
356
+ }
275
357
 
276
- label { display: block; font-size: 12px; font-weight: 600; color: var(--bc-fg); margin-bottom: 6px; }
358
+ label { display: block; font-size: var(--bc-fs-sm); font-weight: 620; color: var(--bc-fg); margin-bottom: 7px; }
359
+ label .opt { font-weight: 450; color: var(--bc-faint); }
277
360
  input[type=text], input[type=email], textarea {
278
- width: 100%; padding: 10px 12px; font-size: 14px; line-height: 1.45;
361
+ width: 100%; padding: 11px 13px; font-size: var(--bc-fs-md); line-height: 1.45;
279
362
  color: var(--bc-fg); background: var(--bc-elevated);
280
- border: 1px solid var(--bc-border); border-radius: 10px;
281
- transition: border-color 160ms, box-shadow 160ms;
363
+ border: 1px solid var(--bc-border); border-radius: var(--bc-r-md);
364
+ transition: border-color 160ms, box-shadow 160ms, background 160ms;
282
365
  }
283
366
  input[type=text]::placeholder, input[type=email]::placeholder, textarea::placeholder { color: var(--bc-faint); }
367
+ input[type=text]:hover, input[type=email]:hover, textarea:hover { border-color: color-mix(in srgb, var(--bc-fg) 18%, var(--bc-border)); }
284
368
  input[type=text]:focus, input[type=email]:focus, textarea:focus {
285
- outline: none; border-color: var(--bc-accent);
286
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--bc-accent) 18%, transparent);
287
- }
288
- input.invalid { border-color: var(--bc-danger); animation: bc-nudge 240ms ease; }
289
- textarea { min-height: 84px; resize: vertical; }
290
- .field { margin-bottom: 14px; }
369
+ outline: none; border-color: var(--bc-accent); box-shadow: var(--bc-ring);
370
+ }
371
+ input.invalid, input.invalid:focus, textarea.invalid {
372
+ border-color: var(--bc-danger); animation: bc-nudge 240ms ease;
373
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--bc-danger) 16%, transparent);
374
+ }
375
+ .field-err {
376
+ display: flex; align-items: center; gap: 6px;
377
+ margin: 7px 0 0; font-size: var(--bc-fs-xs); font-weight: 550; color: var(--bc-danger);
378
+ animation: bc-rise 200ms var(--bc-ease) both;
379
+ }
380
+ .field-err svg { font-size: 13px; flex: none; }
381
+ textarea { min-height: 88px; resize: vertical; }
382
+ .field { margin-bottom: 16px; }
383
+ .field-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
384
+ .field-head .count { font-size: var(--bc-fs-xs); color: var(--bc-faint); font-variant-numeric: tabular-nums; }
385
+
386
+ /* The draft line: a quiet note, or a card offering to pick up where they left off. */
387
+ .compose > .note { font-size: var(--bc-fs-xs); color: var(--bc-faint); margin: -4px 0 10px; }
388
+ .compose > .note:empty { display: none; }
389
+ .compose > .note:has(button) {
390
+ display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px;
391
+ margin: 0 0 14px; padding: 10px 12px; border-radius: var(--bc-r-md);
392
+ background: var(--bc-accent-soft); border: 1px solid var(--bc-accent-line);
393
+ font-size: var(--bc-fs-sm); color: var(--bc-fg);
394
+ }
395
+ .compose > .note button {
396
+ padding: 5px 11px; font: inherit; font-size: var(--bc-fs-xs); font-weight: 620;
397
+ border-radius: 999px; cursor: pointer;
398
+ border: 1px solid var(--bc-accent); background: var(--bc-accent); color: var(--bc-on-accent);
399
+ }
400
+ .compose > .note button[data-discard] { background: transparent; color: var(--bc-muted); border-color: var(--bc-border); }
291
401
 
292
402
  /* ---------- what is already known ----------
293
403
  A help surface in front of the form, not a step in it. Everything here is
@@ -295,34 +405,35 @@ textarea { min-height: 84px; resize: vertical; }
295
405
  leave, tap "this is happening to me too" and leave, or type straight past all
296
406
  of it and file the report they came to file. Nothing is ever in their way,
297
407
  because a reporter who feels handled rather than helped stops reporting. */
298
- .known { margin-bottom: 14px; }
408
+ .known { margin-bottom: 16px; }
299
409
  .known .known-h {
300
- margin: 0 0 8px; font-size: 12px; font-weight: 650; letter-spacing: .02em;
410
+ margin: 0 0 8px; font-size: 11px; font-weight: 680; letter-spacing: .06em;
301
411
  text-transform: uppercase; color: var(--bc-faint);
302
412
  }
303
413
  .known .issue {
304
- display: flex; align-items: flex-start; gap: 10px;
414
+ display: flex; align-items: center; gap: 10px;
305
415
  padding: 10px 12px; margin-bottom: 6px;
306
- background: var(--bc-subtle); border: 1px solid transparent; border-radius: 10px;
416
+ background: var(--bc-subtle); border: 1px solid var(--bc-border); border-radius: var(--bc-r-md);
417
+ animation: bc-rise 240ms var(--bc-ease) both;
307
418
  }
308
- .known .issue.is-joined { border-color: var(--bc-ok); }
419
+ .known .issue.is-joined { border-color: color-mix(in srgb, var(--bc-ok) 55%, var(--bc-border)); }
309
420
  .known .issue .who { flex: 1; min-width: 0; }
310
421
  .known .issue .t {
311
422
  display: block; font-size: 13px; font-weight: 620; line-height: 1.4;
312
423
  color: var(--bc-fg); overflow-wrap: anywhere;
313
424
  }
314
425
  .known .issue .meta {
315
- display: block; margin-top: 3px; font-size: 12px; line-height: 1.45; color: var(--bc-muted);
426
+ display: block; margin-top: 3px; font-size: var(--bc-fs-xs); line-height: 1.45; color: var(--bc-muted);
316
427
  }
317
428
  .known .issue .join {
318
- flex: none; padding: 6px 10px; font-size: 12px; font-weight: 620;
429
+ flex: none; padding: 7px 12px; font-size: 12px; font-weight: 620;
319
430
  color: var(--bc-accent); background: var(--bc-bg);
320
- border: 1px solid var(--bc-border); border-radius: 8px; cursor: pointer;
431
+ border: 1px solid var(--bc-accent-line); border-radius: 999px; cursor: pointer;
432
+ transition: background 160ms, border-color 160ms;
321
433
  }
322
- .known .issue .join:hover { border-color: var(--bc-accent); }
323
- .known .issue .join:disabled { color: var(--bc-ok); border-color: var(--bc-border); cursor: default; }
324
- .known .issue .join:focus-visible { outline: 2px solid var(--bc-accent); outline-offset: 2px; }
325
- .known .ask { display: flex; gap: 8px; margin-top: 8px; }
434
+ .known .issue .join:hover { border-color: var(--bc-accent); background: var(--bc-accent-soft); }
435
+ .known .issue .join:disabled { color: var(--bc-ok); border-color: var(--bc-border); background: var(--bc-bg); cursor: default; opacity: 1; }
436
+ .known .ask { display: flex; gap: 8px; margin: 2px 0 8px; }
326
437
  .known .ask input { flex: 1; min-width: 0; }
327
438
  .known .ask button { flex: none; }
328
439
  .known .err { margin: 6px 0 0; font-size: 12px; line-height: 1.45; color: var(--bc-danger); }
@@ -334,97 +445,109 @@ textarea { min-height: 84px; resize: vertical; }
334
445
  strip is the whole panel. */
335
446
  .whoami {
336
447
  display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
337
- margin-bottom: 14px; padding: 10px 12px;
338
- font-size: 12.5px; line-height: 1.45; color: var(--bc-muted);
339
- background: var(--bc-subtle); border-radius: 10px;
448
+ margin-bottom: 16px; padding: 10px 12px;
449
+ font-size: var(--bc-fs-sm); line-height: 1.45; color: var(--bc-muted);
450
+ background: var(--bc-subtle); border: 1px solid var(--bc-border); border-radius: var(--bc-r-md);
340
451
  }
341
- .whoami.asking { display: block; }
452
+ .whoami.asking { display: block; padding: 14px; }
342
453
  .whoami .who { flex: 1; min-width: 0; }
343
454
  .whoami .who strong { color: var(--bc-fg); font-weight: 620; word-break: break-all; }
344
- .whoami .link {
345
- padding: 0; font-size: 12.5px; font-weight: 600; line-height: 1.45;
346
- color: var(--bc-accent); background: none; border: none; cursor: pointer;
347
- }
348
- .whoami .link:hover { text-decoration: underline; }
349
- .whoami .link:disabled { color: var(--bc-faint); cursor: default; text-decoration: none; }
350
- .whoami .link:focus-visible { outline: 2px solid var(--bc-accent); outline-offset: 2px; border-radius: 4px; }
351
- .whoami .why { margin: 0 0 10px; font-size: 12.5px; line-height: 1.5; color: var(--bc-muted); }
455
+ .link {
456
+ padding: 0; font: inherit; font-size: var(--bc-fs-sm); font-weight: 620; line-height: 1.45;
457
+ color: var(--bc-accent); background: none; border: none; cursor: pointer; border-radius: 4px;
458
+ }
459
+ .link:hover { text-decoration: underline; text-underline-offset: 2px; }
460
+ .link:disabled { color: var(--bc-faint); cursor: default; text-decoration: none; }
461
+ .whoami .why { margin: 0 0 10px; font-size: var(--bc-fs-sm); line-height: 1.5; color: var(--bc-muted); }
352
462
  .whoami .why strong { color: var(--bc-fg); font-weight: 620; word-break: break-all; }
353
463
  .whoami .row { display: flex; gap: 8px; align-items: stretch; }
354
464
  .whoami .row input { flex: 1; min-width: 0; }
355
465
  .whoami .row button { flex: none; }
356
- .whoami .after { display: flex; gap: 12px; align-items: center; margin-top: 9px; }
466
+ .whoami .after { display: flex; gap: 14px; align-items: center; margin-top: 9px; }
357
467
  .whoami .err { color: var(--bc-danger); }
358
468
  .whoami .code { letter-spacing: .28em; font-variant-numeric: tabular-nums; }
359
469
 
360
470
  /* ---------- which record is this about ----------
361
- Shaped like .whoami on purpose: both are the widget asking for something
362
- rather than the reporter telling us something, and a reporter who has learnt
363
- what the grey block means once should not have to learn it twice. Drawn only
364
- after Send has been pressed and only when what they wrote did not already
365
- answer it, so most people never see this at all. */
471
+ Drawn only after Send has been pressed and only when what they wrote did not
472
+ already answer it, so most people never see this at all. Presented as the
473
+ assistant asking one friendly follow-up, not as a form field that appeared. */
366
474
  .refs {
367
- margin-bottom: 14px; padding: 10px 12px;
368
- font-size: 12.5px; line-height: 1.45; color: var(--bc-muted);
369
- background: var(--bc-subtle); border-radius: 10px;
370
- }
371
- .refs .why { margin: 0 0 10px; font-size: 12.5px; line-height: 1.5; color: var(--bc-muted); }
475
+ position: relative;
476
+ margin-bottom: 16px; padding: 14px;
477
+ font-size: var(--bc-fs-sm); line-height: 1.45; color: var(--bc-muted);
478
+ background: var(--bc-accent-soft);
479
+ border: 1px solid var(--bc-accent-line); border-radius: var(--bc-r-lg);
480
+ animation: bc-item-in 280ms var(--bc-ease) both;
481
+ }
482
+ .refs .ask-head { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 11px; }
483
+ .refs .ask-head .avatar { width: 28px; height: 28px; font-size: 14px; box-shadow: none; }
484
+ .refs .ask-head strong { display: block; font-size: 13.5px; font-weight: 650; color: var(--bc-fg); margin-bottom: 1px; }
485
+ .refs .why { margin: 0; font-size: var(--bc-fs-sm); line-height: 1.5; color: var(--bc-muted); }
372
486
  .refs .row { display: flex; gap: 8px; align-items: stretch; }
373
- .refs .row input { flex: 1; min-width: 0; }
487
+ .refs .row input { flex: 1; min-width: 0; background: var(--bc-bg); }
374
488
  .refs select {
375
489
  flex: none; max-width: 45%; padding: 10px 10px; font-size: 13px;
376
- color: var(--bc-fg); background: var(--bc-elevated);
377
- border: 1px solid var(--bc-border); border-radius: 10px;
378
- }
379
- .refs select:focus {
380
- outline: none; border-color: var(--bc-accent);
381
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--bc-accent) 18%, transparent);
382
- }
383
- .refs .hint { margin: 8px 0 0; font-size: 12px; line-height: 1.45; color: var(--bc-faint); }
490
+ color: var(--bc-fg); background: var(--bc-bg);
491
+ border: 1px solid var(--bc-border); border-radius: var(--bc-r-md);
492
+ }
493
+ .refs select:focus { outline: none; border-color: var(--bc-accent); box-shadow: var(--bc-ring); }
494
+ .refs .chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 9px; }
495
+ .refs .chips button {
496
+ padding: 5px 11px; font: inherit; font-size: 12px; font-weight: 600; border-radius: 999px; cursor: pointer;
497
+ color: var(--bc-muted); background: var(--bc-bg); border: 1px solid var(--bc-border);
498
+ transition: background 160ms, color 160ms, border-color 160ms;
499
+ }
500
+ .refs .chips button[aria-pressed="true"] { color: var(--bc-on-accent); background: var(--bc-accent); border-color: var(--bc-accent); }
501
+ .refs .hint { margin: 8px 0 0; font-size: 12px; line-height: 1.45; color: var(--bc-muted); }
384
502
  .refs .after { display: flex; gap: 12px; align-items: center; margin-top: 9px; }
385
- .refs .err { color: var(--bc-danger); }
386
- .refs .link {
387
- margin-top: 9px; padding: 0; font-size: 12.5px; font-weight: 600; line-height: 1.45;
388
- color: var(--bc-accent); background: none; border: none; cursor: pointer;
389
- }
390
- .refs .link:hover { text-decoration: underline; }
391
- .refs .link:focus-visible { outline: 2px solid var(--bc-accent); outline-offset: 2px; border-radius: 4px; }
503
+ .refs .err { color: var(--bc-danger); font-weight: 550; }
504
+ .refs .link { margin-top: 10px; font-size: 12px; color: var(--bc-muted); }
505
+ .refs .link:hover { color: var(--bc-fg); }
392
506
 
393
507
  /* ---------- severity ---------- */
394
- .seg { display: grid; grid-template-columns: repeat(4, 1fr); gap: 3px; padding: 3px; background: var(--bc-subtle); border-radius: 10px; }
508
+ .seg { display: grid; grid-template-columns: repeat(4, 1fr); gap: 3px; padding: 3px; background: var(--bc-subtle); border: 1px solid var(--bc-border); border-radius: var(--bc-r-md); }
395
509
  .seg button {
396
- padding: 7px 4px; font-size: 12.5px; font-weight: 550; line-height: 1;
397
- color: var(--bc-muted); background: none; border: none; border-radius: 7px;
398
- cursor: pointer; transition: background 180ms var(--bc-ease), color 180ms, box-shadow 180ms, font-weight 180ms;
399
- }
510
+ display: inline-flex; align-items: center; justify-content: center; gap: 6px;
511
+ padding: 8px 4px; font-size: var(--bc-fs-sm); font-weight: 550; line-height: 1;
512
+ color: var(--bc-muted); background: none; border: none; border-radius: 9px;
513
+ cursor: pointer; transition: background 180ms var(--bc-ease), color 180ms, box-shadow 180ms;
514
+ }
515
+ .seg button::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: var(--sev, var(--bc-faint)); opacity: .55; transition: opacity 180ms; }
516
+ .seg button[data-prio="LOW"] { --sev: #94a3b8; }
517
+ .seg button[data-prio="MEDIUM"] { --sev: #3b82f6; }
518
+ .seg button[data-prio="HIGH"] { --sev: #f59e0b; }
519
+ .seg button[data-prio="CRITICAL"] { --sev: #ef4444; }
400
520
  .seg button:hover { color: var(--bc-fg); }
401
521
  .seg button[aria-pressed=true] {
402
- background: var(--bc-raised); color: var(--bc-accent); font-weight: 680;
403
- box-shadow: 0 1px 3px rgba(10,12,16,.14);
522
+ background: var(--bc-raised); color: var(--bc-fg); font-weight: 650;
523
+ box-shadow: 0 1px 3px rgba(10,12,16,.14), 0 0 0 1px color-mix(in srgb, var(--bc-border) 60%, transparent);
404
524
  }
405
- .seg button:focus-visible { outline: 2px solid var(--bc-accent); outline-offset: -1px; }
525
+ .seg button[aria-pressed=true]::before { opacity: 1; }
526
+ .seg button:focus-visible { outline-offset: -2px; }
406
527
 
407
528
  /* ---------- attachments ---------- */
408
- .tools { display: flex; flex-wrap: wrap; gap: 6px; }
529
+ .tools { display: grid; grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); gap: 8px; }
409
530
  .tool {
410
- display: inline-flex; align-items: center; gap: 6px;
411
- padding: 8px 12px; font-size: 12.5px; font-weight: 550; line-height: 1;
531
+ display: inline-flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px;
532
+ padding: 12px 8px; font-size: 12px; font-weight: 600; line-height: 1;
412
533
  color: var(--bc-fg); background: var(--bc-elevated);
413
- border: 1px solid var(--bc-border); border-radius: 9px; cursor: pointer;
414
- transition: border-color 160ms, background 160ms, transform 160ms var(--bc-ease);
534
+ border: 1px dashed color-mix(in srgb, var(--bc-fg) 20%, var(--bc-border)); border-radius: var(--bc-r-md); cursor: pointer;
535
+ transition: border-color 160ms, background 160ms, color 160ms, transform 160ms var(--bc-ease);
415
536
  }
416
- .tool svg { font-size: 15px; color: var(--bc-muted); transition: color 160ms; }
417
- .tool:hover:not(:disabled) { border-color: var(--bc-accent); transform: translateY(-1px); }
537
+ .tool svg { font-size: 19px; color: var(--bc-muted); transition: color 160ms; }
538
+ .tool:hover:not(:disabled) { border-color: var(--bc-accent); border-style: solid; background: var(--bc-accent-soft); transform: translateY(-1px); }
418
539
  .tool:hover:not(:disabled) svg { color: var(--bc-accent); }
419
540
  .tool:active:not(:disabled) { transform: translateY(0) scale(.98); }
420
- .tool:focus-visible { outline: 2px solid var(--bc-accent); outline-offset: 1px; }
421
541
  .tool:disabled { opacity: .45; cursor: not-allowed; }
542
+ .attach-hint { margin: 8px 0 0; font-size: var(--bc-fs-xs); color: var(--bc-faint); }
422
543
 
423
544
  .thumbs { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
545
+ .thumbs:empty { display: none; }
424
546
  .thumb {
425
- position: relative; width: 74px; height: 74px; flex: none;
426
- border-radius: 10px; overflow: hidden;
547
+ position: relative; width: 76px; height: 76px; flex: none;
548
+ border-radius: var(--bc-r-md); overflow: hidden;
427
549
  border: 1px solid var(--bc-border); background: var(--bc-subtle);
550
+ box-shadow: var(--bc-shadow-sm);
428
551
  animation: bc-item-in 300ms var(--bc-ease) both;
429
552
  }
430
553
  .thumb img, .thumb video { width: 100%; height: 100%; object-fit: cover; display: block; }
@@ -435,11 +558,11 @@ textarea { min-height: 84px; resize: vertical; }
435
558
  background: rgba(10,12,16,.72); color: #fff; font-size: 9.5px; font-weight: 600; line-height: 1.3;
436
559
  }
437
560
  .thumb .drop {
438
- position: absolute; top: 3px; right: 3px;
439
- width: 19px; height: 19px; font-size: 11px; border-radius: 6px;
440
- background: rgba(10,12,16,.68); color: #fff; border: none;
561
+ position: absolute; top: 4px; right: 4px;
562
+ width: 20px; height: 20px; font-size: 11px; border-radius: 50%;
563
+ background: rgba(10,12,16,.7); color: #fff; border: none;
441
564
  display: grid; place-items: center; cursor: pointer;
442
- opacity: 0; transition: opacity 160ms;
565
+ opacity: 0; transition: opacity 160ms, background 160ms;
443
566
  }
444
567
  .thumb:hover .drop, .thumb:focus-within .drop { opacity: 1; }
445
568
  .thumb .drop:hover { background: var(--bc-danger); }
@@ -463,354 +586,533 @@ textarea { min-height: 84px; resize: vertical; }
463
586
 
464
587
  /* ---------- messages ---------- */
465
588
  .msg {
466
- display: flex; align-items: flex-start; gap: 7px;
467
- font-size: 12.5px; line-height: 1.45; padding: 9px 11px; border-radius: 9px; margin-bottom: 12px;
589
+ display: flex; align-items: flex-start; gap: 8px;
590
+ font-size: var(--bc-fs-sm); line-height: 1.45; padding: 10px 12px; border-radius: var(--bc-r-md); margin-bottom: 14px;
468
591
  animation: bc-item-in 240ms var(--bc-ease) both;
469
592
  }
470
- .msg.err { background: color-mix(in srgb, var(--bc-danger) 11%, transparent); color: var(--bc-danger); }
471
- .msg.info { background: var(--bc-subtle); color: var(--bc-muted); }
593
+ .msg svg { flex: none; font-size: 15px; margin-top: 1px; }
594
+ .msg.err { background: color-mix(in srgb, var(--bc-danger) 10%, var(--bc-bg)); color: var(--bc-danger); border: 1px solid color-mix(in srgb, var(--bc-danger) 30%, transparent); }
595
+ .msg.info { background: var(--bc-subtle); color: var(--bc-muted); border: 1px solid var(--bc-border); }
472
596
 
473
597
  /* ---------- footer ---------- */
474
598
  .foot {
475
- display: flex; align-items: center; gap: 9px;
476
- margin-top: 16px; padding: 14px 18px; border-top: 1px solid var(--bc-border);
477
- background: var(--bc-subtle);
599
+ flex: none;
600
+ display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
601
+ padding: 12px var(--bc-pad); border-top: 1px solid var(--bc-border);
602
+ background: var(--bc-bg);
478
603
  }
479
604
  .foot .spacer { flex: 1; }
480
- .foot .note { font-size: 11.5px; color: var(--bc-faint); }
605
+ .foot .note { flex-basis: 100%; order: -1; font-size: var(--bc-fs-xs); color: var(--bc-muted); }
606
+ .foot .note:empty { display: none; }
481
607
  /* Quiet, but still legible: this sits in someone else's product, so it identifies
482
608
  where the report goes without competing with their interface. Quiet is bought
483
609
  with colour (--bc-brand-ink sits a step below body text) rather than with a
484
610
  blanket opacity — the BUGCATCH half is only about 3px of cap height at this
485
611
  size, and dimming it on top of that left it as a smudge in both themes. */
486
- .brand-credit { display: flex; align-items: center; transition: color 0.15s; }
487
- .bc-brand { height: 18px; width: auto; display: block; color: var(--bc-brand-ink); }
612
+ .powered {
613
+ flex: none; display: flex; align-items: center; justify-content: center; gap: 6px;
614
+ padding: 7px var(--bc-pad) 9px; font-size: 10.5px; color: var(--bc-faint);
615
+ background: var(--bc-subtle); border-top: 1px solid var(--bc-border);
616
+ }
617
+ .brand-credit { display: flex; align-items: center; transition: color 0.15s; border-radius: 4px; }
618
+ .bc-brand { height: 16px; width: auto; display: block; color: var(--bc-brand-ink); }
488
619
  .brand-credit:hover .bc-brand { color: var(--bc-fg); }
489
- .brand-credit:focus-visible { outline: 2px solid var(--bc-accent); outline-offset: 3px; border-radius: 4px; }
490
620
  button.ghost, button.primary {
491
- padding: 9px 15px; font-size: 13px; font-weight: 600; line-height: 1;
492
- border-radius: 9px; cursor: pointer;
493
- transition: transform 160ms var(--bc-ease), filter 160ms, background 160ms, border-color 160ms;
494
- }
495
- button.ghost { background: none; border: 1px solid var(--bc-border); color: var(--bc-muted); }
496
- button.ghost:hover { color: var(--bc-fg); border-color: var(--bc-faint); }
621
+ display: inline-flex; align-items: center; justify-content: center; gap: 7px;
622
+ height: 38px; padding: 0 15px; font: inherit; font-size: 13px; font-weight: 620; line-height: 1;
623
+ border-radius: 999px; cursor: pointer; white-space: nowrap;
624
+ transition: transform 160ms var(--bc-ease), filter 160ms, background 160ms, border-color 160ms, color 160ms, box-shadow 160ms;
625
+ }
626
+ button.ghost { background: var(--bc-bg); border: 1px solid var(--bc-border); color: var(--bc-fg); }
627
+ button.ghost:hover:not(:disabled) { background: var(--bc-subtle); border-color: color-mix(in srgb, var(--bc-fg) 20%, var(--bc-border)); }
628
+ button.ghost svg, button.primary svg { font-size: 15px; }
497
629
  button.primary {
498
- display: inline-flex; align-items: center; gap: 7px;
630
+ padding: 0 18px;
499
631
  background: var(--bc-accent); border: 1px solid var(--bc-accent); color: var(--bc-on-accent);
632
+ box-shadow: 0 4px 12px color-mix(in srgb, var(--bc-accent) 30%, transparent);
500
633
  }
501
634
  button.primary:hover:not(:disabled) { transform: translateY(-1px); filter: brightness(1.07); }
502
635
  button.primary:active:not(:disabled) { transform: none; }
503
- button.ghost:focus-visible, button.primary:focus-visible { outline: 2px solid var(--bc-accent); outline-offset: 2px; }
504
636
  button:disabled { opacity: .5; cursor: not-allowed; }
505
- button.primary .spinner { border-color: rgba(255,255,255,.4); border-top-color: #fff; width: 13px; height: 13px; }
637
+ button.primary:disabled { box-shadow: none; }
638
+ button.primary .spinner { border-color: rgba(255,255,255,.4); border-top-color: #fff; width: 14px; height: 14px; }
639
+ button.quiet {
640
+ padding: 0 4px; height: 38px; font: inherit; font-size: 13px; font-weight: 600;
641
+ border: none; background: none; color: var(--bc-muted); cursor: pointer; border-radius: 8px;
642
+ }
643
+ button.quiet:hover { color: var(--bc-accent); }
506
644
 
507
- /* ---------- success ---------- */
508
645
  /* ---------- your reports ---------- */
509
- .history { padding: 6px 20px 18px; max-height: 340px; overflow-y: auto; }
646
+ .history {
647
+ flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
648
+ padding: 8px 12px 14px;
649
+ }
510
650
  /* On the conversation screen the inner thread does the scrolling, so this
511
651
  wrapper must not — two nested scrollers means the composer drifts out of
512
652
  reach exactly when someone reaches for it. */
513
653
  .history.chatting {
514
- max-height: none; overflow: visible; display: flex; flex-direction: column;
654
+ overflow: hidden; display: flex; flex-direction: column; padding: 0;
515
655
  }
516
656
  .history .row {
517
- display: flex; align-items: flex-start; gap: 10px;
518
- padding: 11px 0; border-bottom: 1px solid var(--bc-border);
657
+ display: flex; align-items: center; gap: 12px;
658
+ padding: 12px 10px; border-bottom: 1px solid var(--bc-border);
519
659
  }
520
660
  .history .row:last-child { border-bottom: 0; }
521
661
  .history .who { flex: 1; min-width: 0; }
522
662
  .history .ttl {
523
- display: block; font-size: 13px; font-weight: 550; color: var(--bc-fg);
663
+ display: block; font-size: 13.5px; font-weight: 600; color: var(--bc-fg);
524
664
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
525
665
  }
526
- .history .sub { display: block; margin-top: 2px; font-size: 11.5px; color: var(--bc-faint); }
527
- .history .empty { padding: 26px 0; text-align: center; font-size: 12.5px; color: var(--bc-muted); }
666
+ .history .sub { display: block; margin-top: 3px; font-size: var(--bc-fs-xs); color: var(--bc-faint); }
667
+ .history .row-ico {
668
+ flex: none; display: grid; place-items: center; width: 34px; height: 34px; border-radius: 10px;
669
+ background: var(--bc-subtle); color: var(--bc-muted); font-size: 16px;
670
+ }
671
+ .history .chev { flex: none; color: var(--bc-faint); font-size: 15px; transition: transform 160ms var(--bc-ease), color 160ms; }
672
+ .empty {
673
+ display: flex; flex-direction: column; align-items: center; gap: 10px;
674
+ padding: 40px 16px; text-align: center; font-size: var(--bc-fs-sm); color: var(--bc-muted);
675
+ }
676
+ .empty .empty-ico {
677
+ display: grid; place-items: center; width: 44px; height: 44px; border-radius: 50%;
678
+ background: var(--bc-accent-soft); color: var(--bc-accent); font-size: 20px;
679
+ }
680
+ .skel {
681
+ display: block; height: 12px; border-radius: 6px; margin: 6px 0;
682
+ background: linear-gradient(90deg, var(--bc-subtle) 25%, color-mix(in srgb, var(--bc-fg) 8%, var(--bc-subtle)) 50%, var(--bc-subtle) 75%);
683
+ background-size: 200% 100%; animation: bc-shimmer 1.4s linear infinite;
684
+ }
528
685
  /* A history row is a button now — it opens the report — so the UA styling has
529
686
  to go, and the hit area has to read as one. */
530
687
  .history button.row {
531
- width: 100%; background: none; border: 0; border-bottom: 1px solid var(--bc-border);
688
+ width: 100%; background: none; border: 0; margin-bottom: 2px;
532
689
  font: inherit; color: inherit; text-align: left; cursor: pointer;
533
- border-radius: 8px; padding: 11px 8px; margin: 0 -8px; width: calc(100% + 16px);
690
+ border-radius: var(--bc-r-md); transition: background 160ms;
691
+ animation: bc-rise 260ms var(--bc-ease) both;
534
692
  }
535
693
  .history button.row:hover { background: var(--bc-subtle); }
536
- .history button.row:focus-visible { outline: 2px solid var(--bc-accent); outline-offset: -2px; }
694
+ .history button.row:hover .chev { transform: translateX(2px); color: var(--bc-accent); }
695
+ .history button.row:focus-visible { outline-offset: -2px; }
537
696
 
538
697
  /* ---------- one report, and what happened to it ---------- */
539
698
  .report-head {
540
- display: grid; grid-template-columns: 1fr auto; gap: 2px 10px;
541
- padding: 4px 0 14px; border-bottom: 1px solid var(--bc-border);
699
+ flex: none;
700
+ display: grid; grid-template-columns: 1fr auto; gap: 2px 10px; align-items: center;
701
+ padding: 10px var(--bc-pad); border-bottom: 1px solid var(--bc-border);
702
+ background: var(--bc-bg);
542
703
  }
543
- .report-head .ttl { font-size: 13.5px; font-weight: 600; color: var(--bc-fg); }
544
- .report-head .sub { font-size: 11.5px; color: var(--bc-faint); }
545
- .report-head .pill { grid-row: 1 / span 2; align-self: center; }
704
+ .report-head .ttl { font-size: 13px; font-weight: 620; color: var(--bc-fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
705
+ .report-head .sub { font-size: var(--bc-fs-xs); color: var(--bc-faint); }
706
+ .report-head .pill { grid-row: 1 / span 2; grid-column: 2; align-self: center; }
546
707
 
547
708
  /* The thread scrolls; the composer under it does not. Both live inside the
548
709
  panel, which stops scrolling as a whole on this screen — a composer that
549
710
  scrolls away is a composer people think isn't there. */
550
711
  .thread {
551
- display: flex; flex-direction: column; gap: 10px;
552
- padding: 14px 0 8px; overflow-y: auto; overscroll-behavior: contain;
553
- max-height: min(46vh, 330px);
712
+ flex: 1 1 auto; min-height: 140px;
713
+ display: flex; flex-direction: column; gap: 4px;
714
+ padding: 14px var(--bc-pad) 12px; overflow-y: auto; overscroll-behavior: contain;
715
+ }
716
+
717
+ /* The day a run of messages happened on, as a centred label on a rule. */
718
+ .day-sep {
719
+ display: flex; align-items: center; gap: 10px; margin: 10px 0 6px;
720
+ font-size: 11px; font-weight: 620; letter-spacing: .02em; color: var(--bc-faint);
554
721
  }
722
+ .day-sep::before, .day-sep::after { content: ''; flex: 1; height: 1px; background: var(--bc-border); }
555
723
 
556
724
  /* A status change is not a message and is not drawn as one: no side, no
557
725
  bubble, no author. Centred and quiet, the way every chat marks the things
558
726
  that happened rather than the things that were said. */
559
727
  .sys {
560
- align-self: center; max-width: 90%; text-align: center;
561
- font-size: 11.5px; line-height: 1.45; color: var(--bc-faint);
728
+ align-self: center; max-width: 90%; text-align: center; margin: 6px 0;
729
+ padding: 4px 10px; border-radius: 999px; background: var(--bc-subtle);
730
+ font-size: var(--bc-fs-xs); line-height: 1.45; color: var(--bc-muted);
731
+ animation: bc-fade-in 240ms ease both;
562
732
  }
563
733
  .sys .dot {
564
734
  display: inline-block; width: 6px; height: 6px; margin-right: 6px;
565
735
  border-radius: 50%; background: var(--bc-muted); vertical-align: middle;
566
736
  }
737
+ .sys .t { color: var(--bc-faint); }
567
738
  .sys.k-status .dot { background: #4da3ff; }
568
739
  .sys.k-severity .dot { background: #e8912a; }
569
740
  .sys.k-assignment .dot { background: #8b7dff; }
570
741
  .sys.k-filed .dot { background: var(--bc-faint); }
571
- .icon-btn.back { margin-right: 2px; }
572
742
 
573
743
  /* ---------- the conversation ----------
574
744
 
575
745
  A status change and a message are different things and are drawn as such: a
576
746
  change is a line on the rail above, a message is a bubble with a side. The
577
- reporter's own words sit right and accented, the team's left and neutral —
578
- the arrangement every chat has taught people to read without a legend. */
579
- .msgs { display: flex; flex-direction: column; gap: 10px; padding: 4px 0 6px; }
580
- .msg-row { display: flex; flex-direction: column; max-width: 84%; gap: 4px; }
581
- .msg-row.mine { align-self: flex-end; align-items: flex-end; }
582
- .msg-row.theirs { align-self: flex-start; }
583
- .msg-who { font-size: 11px; font-weight: 600; color: var(--bc-faint); padding: 0 2px; }
747
+ reporter's own words sit right and accented, the other side's left beside an
748
+ avatar that says who it is — the arrangement every chat has taught people to
749
+ read without a legend. */
750
+ .msg-row, .typing-row {
751
+ display: grid; grid-template-columns: 28px minmax(0, 1fr); column-gap: 8px;
752
+ max-width: 88%; margin-top: 10px;
753
+ animation: bc-rise 260ms var(--bc-ease) both;
754
+ }
755
+ .msg-row.cont { margin-top: 3px; }
756
+ .msg-row > *, .typing-row > * { grid-column: 2; }
757
+ /* Fixed rows, so the avatar lines up with the bubble whatever else the
758
+ message carries (a name above it, answers or a time below it). */
759
+ .msg-row > .msg-who, .typing-row > .msg-who { grid-row: 1; }
760
+ .msg-row > .bubble, .typing-row > .bubble { grid-row: 2; }
761
+ .msg-row > .quick { grid-row: 3; }
762
+ .msg-row > .msg-when { grid-row: 4; }
763
+ .msg-row > .av, .typing-row > .av { grid-column: 1; grid-row: 2; align-self: end; }
764
+ /* In a run of messages the avatar sits by the last bubble and the time under
765
+ it, the way a chat marks where one turn ends. */
766
+ .msg-row:has(+ .msg-row.cont) > .av { visibility: hidden; }
767
+ .msg-row:has(+ .msg-row.cont) > .msg-when { display: none; }
768
+ .msg-row.theirs, .typing-row { align-self: flex-start; }
769
+ .msg-row.mine { align-self: flex-end; grid-template-columns: minmax(0, 1fr); justify-items: end; }
770
+ .msg-row.mine > * { grid-column: 1; }
771
+ .av {
772
+ display: grid; place-items: center; width: 28px; height: 28px; border-radius: 50%;
773
+ font-size: 11px; font-weight: 700; letter-spacing: .02em;
774
+ background: color-mix(in srgb, var(--bc-fg) 10%, var(--bc-bg)); color: var(--bc-fg);
775
+ }
776
+ .av.bot {
777
+ background: linear-gradient(140deg, var(--bc-accent), color-mix(in srgb, var(--bc-accent) 60%, #fff));
778
+ color: var(--bc-on-accent); font-size: 14px;
779
+ }
780
+ .msg-who { font-size: var(--bc-fs-xs); font-weight: 620; color: var(--bc-muted); padding: 0 4px 4px; }
781
+ .msg-who.auto { color: var(--bc-accent); display: inline-flex; align-items: center; gap: 5px; }
782
+ .msg-who .tag {
783
+ padding: 1px 6px; border-radius: 999px; font-size: 9.5px; font-weight: 700; letter-spacing: .04em;
784
+ text-transform: uppercase; background: var(--bc-accent-soft); color: var(--bc-accent);
785
+ }
584
786
  .bubble {
585
- padding: 8px 11px; border-radius: 12px; font-size: 12.5px; line-height: 1.5;
787
+ justify-self: start; max-width: 100%;
788
+ padding: 9px 13px; border-radius: 18px; font-size: 13.5px; line-height: 1.5;
586
789
  background: var(--bc-subtle); color: var(--bc-fg);
587
790
  border: 1px solid var(--bc-border);
791
+ border-bottom-left-radius: 6px;
588
792
  }
589
793
  /* pre-wrap belongs to the text, not the box — on the box it would also render
590
794
  the markup's own indentation. */
591
795
  .msg-text { display: block; white-space: pre-wrap; overflow-wrap: anywhere; }
592
796
  .msg-row.mine .bubble {
797
+ justify-self: end;
593
798
  background: var(--bc-accent); border-color: var(--bc-accent); color: var(--bc-on-accent);
594
- border-bottom-right-radius: 4px;
595
- }
596
- .msg-row.theirs .bubble { border-bottom-left-radius: 4px; }
597
- /* A reply nobody wrote. Outlined rather than recoloured — it is still the
598
- team's side of the conversation — with the label above carrying the meaning,
599
- so it reads the same to someone who cannot see the border at all. */
600
- .msg-row.auto .bubble { border-style: dashed; }
601
- .msg-who.auto { color: var(--bc-accent); }
799
+ border-bottom-left-radius: 18px; border-bottom-right-radius: 6px;
800
+ }
801
+ .msg-row.cont.theirs .bubble { border-top-left-radius: 6px; }
802
+ .msg-row.cont.mine .bubble { border-top-right-radius: 6px; }
803
+ /* A reply nobody wrote: tinted with the accent and labelled "Assistant" above
804
+ it, so it reads the same to someone who cannot see the colour at all. */
805
+ .msg-row.auto .bubble { background: var(--bc-accent-soft); border-color: var(--bc-accent-line); }
602
806
  /* An attachment-only message has no text to wrap, so the bubble would be an
603
807
  empty coloured box around it. */
604
- .bubble.silent { padding: 0; background: none; border: 0; }
605
- .msg-when { font-size: 10.5px; color: var(--bc-faint); padding: 0 2px; }
606
- .msg-row.pending { opacity: .55; }
808
+ .bubble.silent { padding: 0; background: none !important; border: 0; }
809
+ .msg-when { font-size: 10.5px; color: var(--bc-faint); padding: 4px 4px 0; }
810
+ .msg-row.pending { opacity: .6; }
811
+ .msg-row.pending .msg-when::after { content: ' · Sending…'; }
607
812
  .msg-row.failed .bubble { border: 1px solid var(--bc-danger); }
813
+ .msg-row.failed .msg-when { color: var(--bc-danger); }
814
+ .msg-row.failed .msg-when::after { content: ' · Not sent'; }
815
+
816
+ /* The assistant, thinking. Three dots in a bubble where its next message will be. */
817
+ .typing { display: inline-flex; align-items: center; gap: 4px; padding: 12px 14px; justify-self: start; }
818
+ .typing i {
819
+ width: 7px; height: 7px; border-radius: 50%; background: var(--bc-accent); opacity: .45;
820
+ animation: bc-typing 1.2s ease-in-out infinite;
821
+ }
822
+ .typing i:nth-child(2) { animation-delay: .15s; }
823
+ .typing i:nth-child(3) { animation-delay: .3s; }
824
+ .typing-row .msg-when { padding-top: 4px; }
608
825
 
609
826
  .att { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; }
610
827
  .bubble.silent .att { margin-top: 0; }
611
828
  .att img {
612
- display: block; max-width: 100%; max-height: 190px; border-radius: 9px;
829
+ display: block; max-width: 100%; max-height: 190px; border-radius: var(--bc-r-md);
613
830
  border: 1px solid var(--bc-border); object-fit: cover; cursor: zoom-in;
614
831
  }
615
832
  .att audio { width: 232px; max-width: 100%; height: 34px; }
616
- .att video { max-width: 100%; max-height: 190px; border-radius: 9px; }
833
+ .att video { max-width: 100%; max-height: 190px; border-radius: var(--bc-r-md); }
617
834
  .att .file {
618
835
  display: flex; align-items: center; gap: 8px; text-decoration: none;
619
- padding: 8px 10px; border-radius: 9px; font-size: 12px;
836
+ padding: 8px 10px; border-radius: 10px; font-size: 12px;
620
837
  background: var(--bc-elevated); border: 1px solid var(--bc-border); color: var(--bc-fg);
621
838
  }
622
839
  .att .file:hover { border-color: var(--bc-accent); }
623
840
  .att .file .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
624
841
  .att .file .sz { flex: none; color: var(--bc-faint); font-size: 11px; }
625
842
 
626
- /* The assistant's live card, above the conversation. */
627
- .assistant { margin: 8px 12px 4px; padding: 10px 12px; border-radius: 12px; border: 1px solid var(--bc-border); background: var(--bc-subtle); }
628
- .assistant.analyzing, .assistant.confirming, .assistant.needs_info { border-color: color-mix(in srgb, var(--bc-accent) 45%, transparent); }
629
- .as-top { display: flex; gap: 10px; align-items: flex-start; }
630
- .as-orb { flex: none; width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; color: #fff; font-size: 12px; background: var(--bc-accent); }
631
- .as-spin { width: 14px; height: 14px; border-radius: 50%; border: 2px solid rgba(255,255,255,.4); border-top-color: #fff; animation: as-rot .8s linear infinite; }
843
+ /* ---------- the assistant's live card ----------
844
+ A status header pinned between the report and the conversation: where the
845
+ investigation is, as words and as a stepper. It does not scroll with the
846
+ thread, because "is anything happening?" is the question on screen the
847
+ whole time. */
848
+ .assistant {
849
+ flex: none; position: relative; overflow: hidden;
850
+ margin: 10px 12px 0; padding: 12px 14px 12px;
851
+ border-radius: var(--bc-r-lg); border: 1px solid var(--bc-border);
852
+ background: var(--bc-subtle);
853
+ transition: background 400ms ease, border-color 400ms ease;
854
+ }
855
+ .assistant.analyzing, .assistant.confirming, .assistant.needs_info {
856
+ background: var(--bc-accent-soft); border-color: var(--bc-accent-line);
857
+ }
858
+ .assistant.resolved { background: color-mix(in srgb, var(--bc-ok) 9%, var(--bc-bg)); border-color: color-mix(in srgb, var(--bc-ok) 35%, var(--bc-border)); }
859
+ .as-body.entering { animation: bc-rise 360ms var(--bc-ease) both; }
860
+ .as-top { display: flex; gap: 11px; align-items: center; }
861
+ .as-orb {
862
+ flex: none; width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center;
863
+ color: var(--bc-on-accent); font-size: 15px;
864
+ background: linear-gradient(140deg, var(--bc-accent), color-mix(in srgb, var(--bc-accent) 60%, #fff));
865
+ }
866
+ .assistant.resolved .as-orb { background: var(--bc-ok); }
867
+ .assistant.reviewing .as-orb { background: color-mix(in srgb, var(--bc-fg) 55%, var(--bc-bg)); }
868
+ .as-spin { width: 16px; height: 16px; border-radius: 50%; border: 2px solid rgba(255,255,255,.4); border-top-color: #fff; animation: as-rot .8s linear infinite; }
632
869
  @keyframes as-rot { to { transform: rotate(360deg); } }
633
- .as-title { font-weight: 600; font-size: 14px; }
634
- .assistant.analyzing .as-title::after { content: ''; animation: as-dots 1.4s steps(4) infinite; }
635
- @keyframes as-dots { 0% { content: ''; } 25% { content: '.'; } 50% { content: '..'; } 75% { content: '...'; } }
636
- .as-detail { font-size: 12.5px; color: var(--bc-muted); margin-top: 2px; }
637
- .as-steps { list-style: none; display: flex; gap: 6px; margin: 10px 0 0; padding: 0; flex-wrap: wrap; font-size: 11.5px; color: var(--bc-muted); }
638
- .as-steps li { display: flex; align-items: center; gap: 4px; }
639
- .as-steps li span { width: 8px; height: 8px; border-radius: 50%; border: 1.5px solid currentColor; }
640
- .as-steps li.done { color: var(--bc-accent); }
641
- .as-steps li.done span { background: var(--bc-accent); border-color: var(--bc-accent); }
642
- .as-steps li.active { color: var(--bc-fg); font-weight: 600; }
643
- .as-steps li.active span { border-color: var(--bc-accent); animation: as-pulse 1s ease-in-out infinite; }
870
+ .as-text { min-width: 0; flex: 1; }
871
+ .as-title { font-weight: 650; font-size: 13.5px; letter-spacing: -.01em; color: var(--bc-fg); }
872
+ .as-detail { font-size: 12px; line-height: 1.45; color: var(--bc-muted); margin-top: 2px; }
873
+ .as-progress {
874
+ height: 4px; margin: 12px 0 0; border-radius: 999px; overflow: hidden;
875
+ background: color-mix(in srgb, var(--bc-fg) 9%, transparent);
876
+ }
877
+ .as-progress i {
878
+ display: block; height: 100%; border-radius: inherit; background: var(--bc-accent);
879
+ transition: width 700ms var(--bc-ease);
880
+ }
881
+ .assistant.resolved .as-progress i { background: var(--bc-ok); }
882
+ .assistant.analyzing .as-progress i {
883
+ background: linear-gradient(90deg, var(--bc-accent) 0%, color-mix(in srgb, var(--bc-accent) 45%, #fff) 50%, var(--bc-accent) 100%);
884
+ background-size: 200% 100%; animation: bc-shimmer 1.6s linear infinite;
885
+ }
886
+ .as-steps {
887
+ list-style: none; display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px;
888
+ margin: 8px 0 0; padding: 0; font-size: 10.5px; color: var(--bc-faint);
889
+ }
890
+ .as-steps li { display: flex; align-items: center; gap: 5px; min-width: 0; white-space: nowrap; }
891
+ .as-steps li .lbl { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
892
+ .as-steps li .dot { flex: none; width: 8px; height: 8px; border-radius: 50%; border: 1.5px solid currentColor; }
893
+ .as-steps li.done { color: var(--bc-muted); }
894
+ .as-steps li.done .dot { background: var(--bc-accent); border-color: var(--bc-accent); }
895
+ .assistant.resolved .as-steps li.done .dot { background: var(--bc-ok); border-color: var(--bc-ok); }
896
+ .as-steps li.active { color: var(--bc-fg); font-weight: 650; }
897
+ .as-steps li.active .dot { border-color: var(--bc-accent); background: var(--bc-bg); animation: as-pulse 1.2s ease-in-out infinite; }
644
898
  @keyframes as-pulse { 50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--bc-accent) 25%, transparent); } }
645
- @media (prefers-reduced-motion: reduce) { .as-spin, .as-steps li.active span, .assistant.analyzing .as-title::after { animation: none; } }
899
+ @media (prefers-reduced-motion: reduce) { .as-spin, .as-steps li.active .dot, .assistant.analyzing .as-progress i, .typing i { animation: none; } }
646
900
 
647
901
  /* Quick answers under the confirm question — only on the latest message. */
648
- .msg-row .quick { display: none; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
902
+ .msg-row .quick { display: none; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
649
903
  .msg-row:last-child .quick { display: flex; }
650
904
  .quick button {
651
- font: inherit; font-size: 13px; padding: 6px 12px; border-radius: 999px; cursor: pointer;
652
- border: 1px solid var(--bc-accent); background: transparent; color: var(--bc-accent);
905
+ display: inline-flex; align-items: center; gap: 6px;
906
+ font: inherit; font-size: 13px; font-weight: 600; padding: 8px 14px; border-radius: 999px; cursor: pointer;
907
+ border: 1px solid var(--bc-accent); background: var(--bc-bg); color: var(--bc-accent);
908
+ transition: background 160ms, color 160ms, transform 160ms var(--bc-ease);
909
+ animation: bc-item-in 300ms var(--bc-ease) both;
653
910
  }
654
- .quick button:hover { background: color-mix(in srgb, var(--bc-accent) 12%, transparent); }
911
+ .quick button:first-child { background: var(--bc-accent); color: var(--bc-on-accent); }
912
+ .quick button:nth-child(2) { animation-delay: 60ms; }
913
+ .quick button svg { font-size: 13px; }
914
+ .quick button:hover { transform: translateY(-1px); background: var(--bc-accent-soft); }
915
+ .quick button:first-child:hover { background: var(--bc-accent); filter: brightness(1.07); }
655
916
 
656
917
  /* ---------- the composer ---------- */
657
918
  .composer {
658
- position: sticky; bottom: 0; margin-top: auto;
659
- padding: 10px 0 2px;
919
+ flex: none; position: relative;
920
+ padding: 10px 12px max(12px, env(safe-area-inset-bottom, 0px));
660
921
  /* The panel's own background, not --bc-elevated: the two are the same white
661
922
  in light mode but differ in dark, where a raised composer reads as a band
662
923
  laid over the panel rather than the foot of it. */
663
924
  background: var(--bc-bg);
664
925
  border-top: 1px solid var(--bc-border);
665
926
  }
666
- .composer .row { display: flex; align-items: flex-end; gap: 8px; }
927
+ /* One rounded field holding the tools, the text and the send button — the
928
+ shape every chat box has, so nobody has to look for where to type. */
929
+ .composer .row {
930
+ display: flex; align-items: flex-end; gap: 4px;
931
+ padding: 5px; border-radius: 22px;
932
+ background: var(--bc-subtle); border: 1px solid var(--bc-border);
933
+ transition: border-color 160ms, box-shadow 160ms;
934
+ }
935
+ .composer .row:focus-within { border-color: var(--bc-accent); box-shadow: var(--bc-ring); }
667
936
  .composer textarea {
668
- flex: 1; min-height: 38px; max-height: 120px; resize: none; padding: 9px 11px;
669
- font: inherit; font-size: 12.5px; line-height: 1.45;
670
- color: var(--bc-fg); background: var(--bc-subtle);
671
- border: 1px solid var(--bc-border); border-radius: 10px;
937
+ flex: 1; min-width: 0; min-height: 34px; max-height: 120px; resize: none; padding: 7px 6px;
938
+ font: inherit; font-size: 13.5px; line-height: 1.45;
939
+ color: var(--bc-fg); background: transparent; border: 0; border-radius: 0;
672
940
  }
673
- .composer textarea:focus { outline: none; border-color: var(--bc-accent); }
674
- .composer .tools { display: flex; gap: 4px; }
941
+ .composer textarea:hover, .composer textarea:focus { border: 0; box-shadow: none; outline: none; }
942
+ .composer .tools { display: flex; gap: 0; grid-template-columns: none; }
675
943
  .composer .tool {
676
- width: 34px; height: 34px; display: grid; place-items: center; cursor: pointer;
677
- border-radius: 9px; border: 1px solid transparent; background: none;
944
+ width: 34px; height: 34px; min-height: 0; padding: 0; display: grid; place-items: center; cursor: pointer;
945
+ border-radius: 50%; border: 0; background: none; font-size: 17px;
678
946
  color: var(--bc-muted);
679
947
  }
680
- .composer .tool:hover { background: var(--bc-subtle); color: var(--bc-fg); }
948
+ .composer .tool svg { font-size: 17px; }
949
+ .composer .tool:hover:not(:disabled) { background: color-mix(in srgb, var(--bc-fg) 8%, transparent); color: var(--bc-fg); border: 0; transform: none; }
950
+ .composer .tool:hover:not(:disabled) svg { color: var(--bc-fg); }
681
951
  .composer .tool:disabled { opacity: .4; cursor: default; }
682
952
  .composer .tool.recording { color: var(--bc-danger); background: color-mix(in srgb, var(--bc-danger) 12%, transparent); }
953
+ .composer .tool.recording svg { color: var(--bc-danger); }
683
954
  .composer .send {
684
- width: 34px; height: 34px; flex: none; border-radius: 9px; cursor: pointer;
685
- background: var(--bc-accent); border: 1px solid var(--bc-accent); color: #fff;
955
+ width: 34px; height: 34px; flex: none; border-radius: 50%; cursor: pointer; font-size: 16px;
956
+ background: var(--bc-accent); border: 0; color: var(--bc-on-accent);
686
957
  display: grid; place-items: center;
958
+ transition: transform 160ms var(--bc-ease), opacity 160ms, filter 160ms;
687
959
  }
688
- .composer .send:disabled { opacity: .4; cursor: default; }
960
+ .composer .send:hover:not(:disabled) { filter: brightness(1.08); transform: scale(1.05); }
961
+ .composer .send:disabled { opacity: .35; cursor: default; background: var(--bc-faint); }
689
962
  .composer .staged { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 0 8px; }
690
963
  .composer .chip {
691
- display: flex; align-items: center; gap: 6px; max-width: 190px;
692
- padding: 5px 7px; border-radius: 8px; font-size: 11.5px;
964
+ display: flex; align-items: center; gap: 6px; max-width: 200px;
965
+ padding: 5px 7px 5px 10px; border-radius: 999px; font-size: var(--bc-fs-xs);
693
966
  background: var(--bc-subtle); border: 1px solid var(--bc-border); color: var(--bc-muted);
967
+ animation: bc-item-in 240ms var(--bc-ease) both;
694
968
  }
695
- .composer .chip .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
969
+ .composer .chip .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--bc-fg); }
696
970
  .composer .chip button {
697
- flex: none; width: 16px; height: 16px; display: grid; place-items: center;
698
- border: 0; border-radius: 4px; background: none; color: var(--bc-faint); cursor: pointer;
971
+ flex: none; width: 18px; height: 18px; display: grid; place-items: center;
972
+ border: 0; border-radius: 50%; background: none; color: var(--bc-faint); cursor: pointer;
699
973
  }
700
974
  .composer .chip button:hover { color: var(--bc-danger); }
701
975
  .composer .chip.uploading { border-style: dashed; }
702
- .composer .note { padding: 6px 2px 0; font-size: 11.5px; color: var(--bc-faint); }
976
+ .composer .note { padding: 6px 6px 0; font-size: var(--bc-fs-xs); color: var(--bc-muted); }
703
977
  .composer .note.err { color: var(--bc-danger); }
704
978
  .reccount { font-variant-numeric: tabular-nums; }
705
979
 
706
980
  /* Status carries the only colour in this list, so it is the thing the eye
707
981
  lands on — which is what someone opening this screen came to check. */
708
982
  .pill {
709
- flex: none; font-size: 10.5px; font-weight: 650; letter-spacing: .03em;
710
- padding: 3px 8px; border-radius: 999px; text-transform: uppercase;
711
- border: 1px solid currentColor;
983
+ flex: none; display: inline-flex; align-items: center; gap: 5px;
984
+ font-size: 11px; font-weight: 650;
985
+ padding: 3px 9px; border-radius: 999px;
986
+ color: var(--bc-muted); background: var(--bc-subtle);
987
+ }
988
+ .pill::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
989
+ .pill.open { color: #c2410c; background: color-mix(in srgb, #f59e0b 14%, var(--bc-bg)); }
990
+ .pill.in_progress { color: #1d6fd1; background: color-mix(in srgb, #3b82f6 14%, var(--bc-bg)); }
991
+ .pill.resolved { color: #0f8a5a; background: color-mix(in srgb, #10b981 14%, var(--bc-bg)); }
992
+ .pill.closed { color: var(--bc-muted); }
993
+ :host(.bc-dark) .pill.open { color: #fbbf24; }
994
+ :host(.bc-dark) .pill.in_progress { color: #7cb8ff; }
995
+ :host(.bc-dark) .pill.resolved { color: #4ade9a; }
996
+ @media (prefers-color-scheme: dark) {
997
+ :host(.bc-auto) .pill.open { color: #fbbf24; }
998
+ :host(.bc-auto) .pill.in_progress { color: #7cb8ff; }
999
+ :host(.bc-auto) .pill.resolved { color: #4ade9a; }
712
1000
  }
713
- .pill.open { color: #e8912a; }
714
- .pill.in_progress { color: #4da3ff; }
715
- .pill.resolved { color: #35b87f; }
716
- .pill.closed { color: var(--bc-faint); }
1001
+
1002
+ /* ---------- does the fix work ---------- */
1003
+ .fix-verification {
1004
+ flex: none; margin: 10px 12px 0; padding: 12px 14px;
1005
+ border-radius: var(--bc-r-lg); border: 1px solid color-mix(in srgb, var(--bc-ok) 35%, var(--bc-border));
1006
+ background: color-mix(in srgb, var(--bc-ok) 8%, var(--bc-bg));
1007
+ font-size: var(--bc-fs-sm); color: var(--bc-fg);
1008
+ animation: bc-rise 280ms var(--bc-ease) both;
1009
+ }
1010
+ .fix-verification p { margin: 0 0 8px; font-weight: 620; }
1011
+ .fix-verification textarea { min-height: 56px; margin-bottom: 8px; font-size: 13px; background: var(--bc-bg); }
1012
+ .fix-verification button {
1013
+ padding: 7px 13px; font: inherit; font-size: 12.5px; font-weight: 620; border-radius: 999px; cursor: pointer;
1014
+ border: 1px solid var(--bc-border); background: var(--bc-bg); color: var(--bc-fg);
1015
+ }
1016
+ .fix-verification button[data-result="CONFIRMED"] { background: var(--bc-ok); border-color: var(--bc-ok); color: #fff; }
1017
+ .fix-verification .feedback-status { margin: 6px 0 0; font-weight: 450; color: var(--bc-danger); font-size: var(--bc-fs-xs); }
1018
+ .fix-verification .feedback-status:empty { display: none; }
717
1019
 
718
1020
  /* A call in progress, in place of the form.
719
1021
  ---------------------------------------------------------------------------
720
1022
  Deliberately not styled as a chat. Nothing arrives here to read — the
721
1023
  conversation is happening out loud — so this is a state, a line of
722
1024
  reassurance about the report, and a way to hang up. */
723
- .call { padding: 20px 22px 14px; animation: bc-fade-in 240ms ease both; }
724
- .call-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
725
- .call-head strong { font-size: 14.5px; font-weight: 650; }
1025
+ .call { display: flex; flex-direction: column; padding: 26px 0 0; animation: bc-fade-in 240ms ease both; }
1026
+ .call-head, .call-note { padding: 0 var(--bc-pad); }
1027
+ .call-head { display: flex; align-items: center; gap: 9px; margin-bottom: 8px; }
1028
+ .call-head strong { font-size: 15.5px; font-weight: 680; letter-spacing: -.01em; }
726
1029
  .call-dot {
727
- width: 8px; height: 8px; border-radius: 50%; background: var(--bc-accent);
1030
+ width: 9px; height: 9px; border-radius: 50%; background: var(--bc-accent);
728
1031
  animation: bc-call-pulse 1.6s ease-in-out infinite;
729
1032
  }
730
1033
  .call-dot.off { background: var(--bc-faint); animation: none; }
731
1034
  @keyframes bc-call-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } }
732
1035
  /* Said while it is still ringing, because that is when somebody is deciding
733
1036
  whether it is worth waiting. */
734
- .call-note { margin: 0 0 12px; font-size: 12px; color: var(--bc-muted); line-height: 1.5; }
1037
+ .call-note { margin: 0 0 18px; font-size: 13px; color: var(--bc-muted); line-height: 1.55; }
735
1038
 
736
1039
  /* ---------- the same call, on top of the report it is about ---------- */
737
1040
  /* A bar rather than its own screen, unlike the one the composer opens. The
738
1041
  thread underneath is exactly what both people are talking about, so taking it
739
1042
  off screen would be taking away the only shared reference. */
740
1043
  .livecall {
1044
+ flex: none;
741
1045
  display: flex; align-items: center; gap: 9px;
742
- margin: 12px 0 0; padding: 9px 11px;
743
- border: 1px solid var(--bc-border); border-radius: 10px;
744
- background: var(--bc-subtle); font-size: 12.5px; color: var(--bc-fg);
1046
+ margin: 10px 12px 0; padding: 9px 11px;
1047
+ border: 1px solid var(--bc-accent-line); border-radius: var(--bc-r-md);
1048
+ background: var(--bc-accent-soft); font-size: var(--bc-fs-sm); color: var(--bc-fg);
1049
+ animation: bc-rise 240ms var(--bc-ease) both;
745
1050
  }
746
1051
  .livecall .who { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
747
1052
  .livecall .secs { font-variant-numeric: tabular-nums; color: var(--bc-muted); }
748
1053
  .livecall .act {
749
- padding: 5px 10px; border-radius: 999px; border: 1px solid var(--bc-border);
750
- background: var(--bc-elevated); color: var(--bc-fg);
751
- font: inherit; font-size: 12px; cursor: pointer;
1054
+ padding: 5px 11px; border-radius: 999px; border: 1px solid var(--bc-border);
1055
+ background: var(--bc-bg); color: var(--bc-fg);
1056
+ font: inherit; font-size: 12px; font-weight: 600; cursor: pointer;
752
1057
  }
753
1058
  .livecall .act:hover { background: var(--bc-subtle); }
754
1059
  .livecall .act.danger { border-color: transparent; background: var(--bc-danger); color: #fff; }
755
- .livecall.over { color: var(--bc-muted); }
1060
+ .livecall.over { color: var(--bc-muted); background: var(--bc-subtle); border-color: var(--bc-border); }
756
1061
  .call-note code { font-size: 11px; }
757
1062
  @media (prefers-reduced-motion: reduce) { .call-dot { animation: none; } }
758
1063
 
759
- .done { padding: 34px 24px 38px; text-align: center; animation: bc-fade-in 240ms ease both; }
1064
+ /* div.done, not .done: the assistant's stepper marks finished steps li.done,
1065
+ and this screen's padding was landing on every one of them. */
1066
+ div.done { padding: 44px 26px 44px; text-align: center; animation: bc-fade-in 240ms ease both; }
760
1067
  .done .tick {
761
- width: 46px; height: 46px; margin: 0 auto 14px; border-radius: 50%;
762
- display: grid; place-items: center; font-size: 24px;
1068
+ width: 56px; height: 56px; margin: 0 auto 16px; border-radius: 50%;
1069
+ display: grid; place-items: center; font-size: 26px;
763
1070
  background: color-mix(in srgb, var(--bc-ok) 14%, transparent); color: var(--bc-ok);
1071
+ box-shadow: 0 0 0 8px color-mix(in srgb, var(--bc-ok) 6%, transparent);
764
1072
  animation: bc-pop 420ms var(--bc-ease) both;
765
1073
  }
766
- .done h3 { margin: 0 0 5px; font-size: 15.5px; font-weight: 650; }
767
- .done p { margin: 0; font-size: 12.5px; color: var(--bc-muted); }
768
- .done code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
1074
+ .done h3 { margin: 0 0 6px; font-size: 17px; font-weight: 680; letter-spacing: -.015em; }
1075
+ .done p { margin: 0; font-size: 13px; line-height: 1.5; color: var(--bc-muted); }
1076
+ .done code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; padding: 1px 5px; border-radius: 5px; background: var(--bc-subtle); color: var(--bc-fg); }
769
1077
  /* The likely wait. Set apart from the reference line above it by a rule rather
770
1078
  than by weight: one is bookkeeping and the other is the answer to the
771
1079
  question the reporter actually has, and they should not read as one
772
1080
  paragraph. */
773
1081
  .done .eta {
774
- margin: 12px auto 0;
775
- padding-top: 11px;
776
- border-top: 1px solid var(--bc-line);
1082
+ margin: 14px auto 0;
1083
+ padding-top: 12px;
1084
+ border-top: 1px solid var(--bc-border);
777
1085
  max-width: 30ch;
778
1086
  line-height: 1.5;
779
1087
  }
780
- .done .eta strong { color: var(--bc-ink); font-weight: 600; }
1088
+ .done .eta strong { color: var(--bc-fg); font-weight: 620; }
781
1089
  /* The same figure on the report the reporter came back to. Sits under the
782
1090
  status pill in the header block, muted — it is context, not the headline the
783
1091
  status is. */
784
1092
  .report-head .eta-line {
785
1093
  grid-column: 1 / -1;
786
- font-size: 11.5px;
1094
+ font-size: var(--bc-fs-xs);
787
1095
  color: var(--bc-muted);
788
1096
  margin-top: 2px;
789
1097
  }
790
1098
 
791
1099
  /* ---------- recording bar ---------- */
792
1100
  .recbar {
793
- position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%);
1101
+ position: fixed; left: 50%; bottom: calc(24px + env(safe-area-inset-bottom, 0px)); transform: translateX(-50%);
794
1102
  z-index: 2147483002;
795
1103
  display: flex; align-items: center; gap: 12px;
796
- padding: 10px 12px 10px 15px; border-radius: 999px;
1104
+ padding: 8px 8px 8px 16px; border-radius: 999px;
797
1105
  background: var(--bc-elevated); color: var(--bc-fg);
798
1106
  border: 1px solid var(--bc-border); box-shadow: var(--bc-shadow);
799
1107
  animation: bc-item-in 300ms var(--bc-ease) both;
800
1108
  }
801
1109
  .recbar .dot { width: 9px; height: 9px; border-radius: 50%; background: var(--bc-danger); animation: bc-pulse 1.4s ease-in-out infinite; }
802
- .recbar .time { font-variant-numeric: tabular-nums; font-size: 13px; font-weight: 600; min-width: 38px; }
1110
+ .recbar .time { font-variant-numeric: tabular-nums; font-size: 13px; font-weight: 650; min-width: 38px; }
803
1111
  .recbar .hint { font-size: 12px; color: var(--bc-muted); }
804
- .recbar button { padding: 7px 13px; font-size: 12.5px; font-weight: 600; border-radius: 999px; cursor: pointer; }
1112
+ .recbar button { height: 34px; padding: 0 14px; font-size: 12.5px; font-weight: 620; border-radius: 999px; cursor: pointer; }
805
1113
  .recbar .stop { background: var(--bc-danger); border: 1px solid var(--bc-danger); color: #fff; }
806
1114
  .recbar .stop:hover { filter: brightness(1.08); }
807
1115
 
808
- @media (max-width: 520px) {
809
- .panel { width: calc(100vw - 24px); max-width: none; }
810
- .scrim.at-right .panel, .scrim.at-left .panel { left: 12px; right: 12px; }
811
- .recbar .hint { display: none; }
812
- }
813
-
814
1116
  /* ---------- phones ----------------------------------------------------------
815
1117
  This widget runs inside a shadow root on somebody else's site, so its own
816
1118
  stylesheet is the whole story: the host page cannot correct any of this, and
@@ -835,6 +1137,32 @@ button.primary .spinner { border-color: rgba(255,255,255,.4); border-top-color:
835
1137
  padding-left: env(safe-area-inset-left, 0px);
836
1138
  }
837
1139
 
1140
+ /* Short desktop windows: the chat height gives way to the space there is. */
1141
+ @media (max-height: 820px) { .panel.is-chat { height: calc(100vh - var(--bc-dock-h, 84px) - 52px); } }
1142
+
1143
+ /* A phone gets the whole screen: a sheet that slides up from the bottom, with
1144
+ the notch and the home indicator kept clear. The dock steps aside while it
1145
+ is open — the sheet has its own close button, and a launcher floating over
1146
+ the send button would be in the way of the one thing that matters. */
1147
+ @media (max-width: 480px) {
1148
+ .scrim { background: rgba(10,12,16,.4); }
1149
+ .panel, .scrim.at-right .panel, .scrim.at-left .panel, .scrim.at-bottom .panel, .scrim.at-top .panel {
1150
+ inset: 0; width: 100%; max-width: none; height: 100%; max-height: none;
1151
+ border-radius: 0; border: 0;
1152
+ animation: bc-sheet-in 380ms var(--bc-ease) both;
1153
+ }
1154
+ @supports (height: 100dvh) { .panel { height: 100dvh; max-height: none; } }
1155
+ .panel.is-chat { height: 100%; }
1156
+ .scrim.closing .panel { animation: bc-sheet-out 200ms ease both; }
1157
+ .head { padding-top: max(14px, env(safe-area-inset-top, 0px)); padding-left: max(var(--bc-pad), env(safe-area-inset-left, 0px)); padding-right: max(12px, env(safe-area-inset-right, 0px)); }
1158
+ .foot, .powered { padding-bottom: max(10px, env(safe-area-inset-bottom, 0px)); }
1159
+ .foot + .powered { padding-bottom: max(9px, env(safe-area-inset-bottom, 0px)); }
1160
+ .foot:has(+ .powered) { padding-bottom: 12px; }
1161
+ .dock.over { display: none; }
1162
+ .recbar .hint { display: none; }
1163
+ .msg-row, .typing-row { max-width: 92%; }
1164
+ }
1165
+
838
1166
  @media (pointer: coarse) {
839
1167
  /* iOS zooms the whole page when a field it focuses is under 16px, and does
840
1168
  not zoom back out. On the host's site that is not the widget's page to
@@ -846,47 +1174,33 @@ button.primary .spinner { border-color: rgba(255,255,255,.4); border-top-color:
846
1174
  /* 44px is the floor a thumb hits reliably. Every control in this form was
847
1175
  under it, on the one surface in the product designed for someone who is
848
1176
  not an operator and gets no second attempt. */
849
- .launcher { min-height: 48px; padding: 14px 20px; }
850
- .seg button { min-height: 44px; padding: 12px 8px; }
851
- .tool { min-height: 44px; padding: 12px 14px; }
852
- button.primary, .foot button { min-height: 44px; padding: 12px 18px; }
1177
+ .seg button { min-height: 44px; }
1178
+ .tool { min-height: 56px; }
1179
+ button.primary, button.ghost { height: 44px; }
1180
+ .composer .send, .composer .tool { width: 40px; height: 40px; min-height: 0; }
853
1181
  textarea { min-height: 104px; }
1182
+ .composer textarea { min-height: 40px; }
854
1183
 
855
- /* The theme switch and the close button are 28px glyphs whose boxes cannot
1184
+ /* The theme switch and the close button are small glyphs whose boxes cannot
856
1185
  grow without moving the header text beside them, so each keeps its size
857
1186
  and gets an invisible target drawn around it instead. */
858
- .themer button, .head .icon-btn, .thumb .x, .brand-credit { position: relative; }
859
- .themer button::after, .head .icon-btn::after, .thumb .x::after, .brand-credit::after {
1187
+ .themer button, .head .icon-btn, .thumb .drop, .brand-credit { position: relative; }
1188
+ .thumb .drop { position: absolute; opacity: 1; }
1189
+ .themer button::after, .head .icon-btn::after, .thumb .drop::after, .brand-credit::after {
860
1190
  content: '';
861
1191
  position: absolute;
862
- inset: -9px;
1192
+ inset: -8px;
863
1193
  }
864
- /* The close button is the one people reach for in a hurry, and it sits in
865
- the corner where a thumb is least accurate. */
866
- .head .icon-btn::after { inset: -12px; }
867
1194
  /* The themer's three buttons sit side by side, so their expanders would
868
1195
  overlap each other without a gap to grow into. */
869
1196
  .themer { gap: 4px; }
870
-
871
- /* Thumb-sized controls make the form taller, which on a short phone pushed
872
- "Send report" below the fold of a panel that scrolls — so the one button
873
- the whole widget exists for was reachable only by scrolling past the
874
- attachment row. It stays on screen instead. */
875
- .foot {
876
- position: sticky;
877
- bottom: 0;
878
- margin-top: 16px;
879
- /* The panel scrolls under this, so the bar cannot be translucent. */
880
- background: var(--bc-subtle);
881
- padding-bottom: max(14px, env(safe-area-inset-bottom, 0px));
882
- }
883
1197
  }
884
1198
 
885
1199
  /* Hover is a lie on a touchscreen: it latches on tap and stays. The launcher's
886
1200
  lift in particular was left applied after the panel closed. */
887
1201
  @media (hover: none) {
888
- .launcher:hover { transform: none; filter: none; box-shadow: 0 6px 18px rgba(10,12,16,.22), 0 1px 3px rgba(10,12,16,.16); }
889
- .tool:hover, .themer button:hover { border-color: var(--bc-border); }
1202
+ .launcher:hover { transform: none; filter: none; }
1203
+ .tool:hover:not(:disabled), .themer button:hover { transform: none; }
890
1204
  }
891
1205
  `;
892
1206
 
@@ -934,7 +1248,8 @@ export function mountWidget(client) {
934
1248
  const launcher = document.createElement('button');
935
1249
  launcher.className = 'launcher';
936
1250
  launcher.type = 'button';
937
- launcher.innerHTML = `${ICONS.bug}<span>Report a bug</span>`;
1251
+ launcher.innerHTML = `<span class="ico" aria-hidden="true"><span class="when-closed">${ICONS.chat}</span>`
1252
+ + `<span class="when-open">${ICONS.chevronDown}</span></span><span>Report a bug</span>`;
938
1253
  launcher.setAttribute('aria-haspopup', 'dialog');
939
1254
  launcher.setAttribute('aria-expanded', 'false');
940
1255
 
@@ -962,7 +1277,14 @@ export function mountWidget(client) {
962
1277
  root.appendChild(dock);
963
1278
 
964
1279
  document.body.appendChild(host);
965
- return () => { open?.destroy(); host.remove(); };
1280
+ // Integration hook: BugCatch.open() / close() / isOpen() drive the same
1281
+ // launcher a person would press, so there is one way in to the panel.
1282
+ client.widgetControls = {
1283
+ open: () => { if (!open) launcher.click(); },
1284
+ close: () => { open?.close(); },
1285
+ isOpen: () => Boolean(open),
1286
+ };
1287
+ return () => { open?.destroy(); host.remove(); client.widgetControls = null; };
966
1288
  }
967
1289
 
968
1290
  /** Normalises a configured theme to one of the three the switch offers. */
@@ -998,26 +1320,25 @@ function openPanel({ root, host, dock, client, onClose }) {
998
1320
  scrim.style.setProperty('--bc-dock-h', `${Math.round(dock.getBoundingClientRect().height)}px`);
999
1321
  scrim.innerHTML = `
1000
1322
  <div class="panel" role="dialog" aria-modal="true" aria-labelledby="bc-h">
1001
- <div class="head">
1002
- <div class="mark">${ICONS.bug}</div>
1003
- <div class="titles">
1004
- <h2 id="bc-h">Report a bug</h2>
1005
- <p>We'll attach what the page was doing, so you don't have to explain it.</p>
1006
- </div>
1007
- <button class="icon-btn" type="button" data-act="close" aria-label="Close">${ICONS.close}</button>
1008
- </div>
1323
+ ${headHtml({
1324
+ id: 'bc-h',
1325
+ title: 'Tell us what went wrong',
1326
+ sub: "Our assistant starts looking right away. We attach the page details for you.",
1327
+ })}
1009
1328
  <div class="body">
1010
- <div class="slot"></div>
1329
+ <div class="slot" role="alert"></div>
1011
1330
  <div class="whoami" hidden></div>
1012
1331
  <div class="known" hidden></div>
1013
1332
  <div class="compose">
1014
1333
  <div class="field">
1015
1334
  <label for="bc-title">What went wrong?</label>
1016
- <input id="bc-title" type="text" placeholder="Checkout button does nothing" autocomplete="off" />
1335
+ <input id="bc-title" type="text" placeholder="e.g. The checkout button does nothing" autocomplete="off"
1336
+ aria-describedby="bc-title-err" />
1337
+ <p class="field-err" id="bc-title-err" hidden></p>
1017
1338
  </div>
1018
1339
  <div class="field">
1019
- <label for="bc-desc">Any detail that helps <span style="font-weight:450;color:var(--bc-faint)">(optional)</span></label>
1020
- <textarea id="bc-desc" placeholder="I clicked Pay and nothing happened."></textarea>
1340
+ <label for="bc-desc">Anything else that helps <span class="opt">(optional)</span></label>
1341
+ <textarea id="bc-desc" placeholder="What you were doing, what you expected, what happened instead."></textarea>
1021
1342
  </div>
1022
1343
  <!-- Drawn only when this project asks for an identifier and the report
1023
1344
  does not already carry one. Between the description and the
@@ -1025,7 +1346,7 @@ function openPanel({ root, host, dock, client, onClose }) {
1025
1346
  it is a question about what they just typed. -->
1026
1347
  <div class="refs" hidden></div>
1027
1348
  <div class="field">
1028
- <label id="bc-prio-l">Severity</label>
1349
+ <label id="bc-prio-l">How much is it getting in your way?</label>
1029
1350
  <div class="seg" role="group" aria-labelledby="bc-prio-l">
1030
1351
  ${PRIORITIES.map((p) => `
1031
1352
  <button type="button" data-prio="${p.value}" aria-pressed="${p.value === priority}">${p.label}</button>
@@ -1034,11 +1355,14 @@ function openPanel({ root, host, dock, client, onClose }) {
1034
1355
  </div>
1035
1356
  ${canCapture ? `
1036
1357
  <div class="field">
1037
- <label>Attach <span style="font-weight:450;color:var(--bc-faint)">(optional)</span></label>
1038
- <div class="tools">
1358
+ <div class="field-head">
1359
+ <label id="bc-att-l">Show us <span class="opt">(optional)</span></label>
1360
+ <span class="count" aria-live="polite"></span>
1361
+ </div>
1362
+ <div class="tools" role="group" aria-labelledby="bc-att-l">
1039
1363
  ${canShoot ? `<button class="tool" type="button" data-act="shot">${ICONS.camera}Screenshot</button>` : ''}
1040
- ${canRecord ? `<button class="tool" type="button" data-act="rec">${ICONS.video}Record</button>` : ''}
1041
- <button class="tool" type="button" data-act="file">${ICONS.paperclip}File</button>
1364
+ ${canRecord ? `<button class="tool" type="button" data-act="rec">${ICONS.video}Record screen</button>` : ''}
1365
+ <button class="tool" type="button" data-act="file">${ICONS.paperclip}Upload file</button>
1042
1366
  </div>
1043
1367
  <div class="thumbs"></div>
1044
1368
  <input type="file" accept="${REPORT_MIMES.join(',')}" multiple hidden />
@@ -1046,22 +1370,26 @@ function openPanel({ root, host, dock, client, onClose }) {
1046
1370
  </div>
1047
1371
  </div>
1048
1372
  <div class="foot">
1049
- <a class="brand-credit" href="https://bugcatch.sphoro.com" target="_blank" rel="noopener noreferrer" aria-label="BugCatch">${BRAND}</a>
1050
- <span class="note"></span>
1373
+ <span class="note" aria-live="polite"></span>
1374
+ ${client.hasReports() ? '<button class="quiet" type="button" data-act="history">Your reports</button>' : ''}
1051
1375
  <span class="spacer"></span>
1052
- ${client.hasReports() ? '<button class="ghost" type="button" data-act="history">Your reports</button>' : ''}
1053
- <button class="ghost" type="button" data-act="cancel">Cancel</button>
1054
1376
  <!-- Hidden until the API confirms this project can take a call, which
1055
1377
  most cannot. Drawn here rather than as a second launcher because it
1056
1378
  is the same errand as Send report — the difference is only whether
1057
1379
  the reporter would rather say it than type it. -->
1058
- <button class="ghost" type="button" data-act="call" hidden>Talk to the team</button>
1059
- <button class="primary" type="button" data-act="send">Send report</button>
1380
+ <button class="ghost" type="button" data-act="call" hidden>${ICONS.phone}Talk to the team</button>
1381
+ <button class="primary" type="button" data-act="send">Send report ${ICONS.send}</button>
1382
+ </div>
1383
+ <div class="powered">Powered by
1384
+ <a class="brand-credit" href="https://bugcatch.sphoro.com" target="_blank" rel="noopener noreferrer" aria-label="BugCatch">${BRAND}</a>
1060
1385
  </div>
1061
1386
  </div>`;
1062
1387
 
1063
1388
  const $ = (sel) => scrim.querySelector(sel);
1064
1389
  const titleInput = $('#bc-title');
1390
+ // Held, not looked up: the history and report screens replace the panel's
1391
+ // markup, and a draft saved on the way out must still read what was typed.
1392
+ const descInput = $('#bc-desc');
1065
1393
  const slot = $('.slot');
1066
1394
  const thumbs = $('.thumbs');
1067
1395
  const filePicker = scrim.querySelector('input[type=file]');
@@ -1086,8 +1414,8 @@ function openPanel({ root, host, dock, client, onClose }) {
1086
1414
  function persistDraft() {
1087
1415
  clearTimeout(draftTimer);
1088
1416
  if (!draftLoaded || draftSubmitted || cfg.drafts === false) return draftWrites;
1089
- const value = titleInput.value || $('#bc-desc').value || items.length ? {
1090
- title: titleInput.value, description: $('#bc-desc').value, priority,
1417
+ const value = titleInput.value || descInput.value || items.length ? {
1418
+ title: titleInput.value, description: descInput.value, priority,
1091
1419
  items: items.map(({ blob, mime, kind }) => ({ blob, mime, kind })),
1092
1420
  } : null;
1093
1421
  draftWrites = draftWrites.catch(() => {}).then(() => draftOperation(savedDraftKey, value))
@@ -1107,10 +1435,10 @@ function openPanel({ root, host, dock, client, onClose }) {
1107
1435
  compose.inert = true;
1108
1436
  readDraft(savedDraftKey).then((draft) => {
1109
1437
  if (closed || !draft) return;
1110
- draftStatus.innerHTML = 'Unfinished report on this device. <button type="button" data-resume>Resume report</button> <button type="button" data-discard>Discard</button>';
1438
+ draftStatus.innerHTML = '<span>You have an unfinished report on this device.</span> <button type="button" data-resume>Resume report</button> <button type="button" data-discard>Discard</button>';
1111
1439
  draftStatus.querySelector('[data-resume]').onclick = () => {
1112
1440
  titleInput.value = draft.title || '';
1113
- $('#bc-desc').value = draft.description || '';
1441
+ descInput.value = draft.description || '';
1114
1442
  priority = PRIORITIES.some((p) => p.value === draft.priority) ? draft.priority : 'MEDIUM';
1115
1443
  scrim.querySelectorAll('[data-prio]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.prio === priority)));
1116
1444
  for (const item of (draft.items || []).slice(0, cfg.maxAttachments ?? 5)) addItem(item);
@@ -1188,7 +1516,6 @@ function openPanel({ root, host, dock, client, onClose }) {
1188
1516
  document.addEventListener('keydown', onKeydown, true);
1189
1517
  scrim.addEventListener('click', (e) => { if (e.target === scrim && !sending) close(); });
1190
1518
  $('[data-act="close"]').addEventListener('click', close);
1191
- $('[data-act="cancel"]').addEventListener('click', close);
1192
1519
 
1193
1520
  /* ---------- severity ---------- */
1194
1521
 
@@ -1205,16 +1532,32 @@ function openPanel({ root, host, dock, client, onClose }) {
1205
1532
  /* ---------- attachments ---------- */
1206
1533
 
1207
1534
  function say(text, kind = 'err') {
1208
- slot.innerHTML = text ? `<div class="msg ${kind}">${escapeHtml(text)}</div>` : '';
1535
+ slot.innerHTML = text
1536
+ ? `<div class="msg ${kind}">${kind === 'err' ? ICONS.alert : ''}<span>${escapeHtml(text)}</span></div>`
1537
+ : '';
1209
1538
  }
1210
1539
 
1540
+ // The one required field says what is missing right under itself, rather
1541
+ // than in a banner the reporter has to connect back to it.
1542
+ const titleErr = $('#bc-title-err');
1543
+ function flagTitle(text) {
1544
+ titleErr.hidden = !text;
1545
+ titleErr.innerHTML = text ? `${ICONS.alert}<span>${escapeHtml(text)}</span>` : '';
1546
+ titleInput.classList.toggle('invalid', Boolean(text));
1547
+ titleInput.setAttribute('aria-invalid', String(Boolean(text)));
1548
+ }
1549
+ titleInput.addEventListener('input', () => { if (titleInput.value.trim()) flagTitle(''); });
1550
+
1211
1551
  function refreshSendState() {
1212
1552
  const busy = items.some((it) => it.status === 'uploading');
1213
1553
  sendBtn.disabled = sending || busy || items.some((it) => it.status === 'failed');
1214
1554
  note.textContent = busy ? 'Waiting for the upload to finish…' : items.some((it) => it.status === 'failed') ? 'Retry or remove failed attachments before sending.' : '';
1555
+ const max = cfg.maxAttachments ?? 5;
1215
1556
  scrim.querySelectorAll('.tool').forEach((t) => {
1216
- t.disabled = sending || items.length >= (cfg.maxAttachments ?? 5);
1557
+ t.disabled = sending || items.length >= max;
1217
1558
  });
1559
+ const count = $('.field-head .count');
1560
+ if (count) count.textContent = items.length ? `${items.length} of ${max}` : '';
1218
1561
  }
1219
1562
 
1220
1563
  function drawThumb(item) {
@@ -1426,7 +1769,7 @@ function openPanel({ root, host, dock, client, onClose }) {
1426
1769
  /** What the report already carries, read out of what they typed. */
1427
1770
  function referencesInText() {
1428
1771
  if (refConfig.policy === 'off' || !refConfig.fields.length) return [];
1429
- return matchReferences(refConfig.fields, [titleInput.value, $('#bc-desc').value]);
1772
+ return matchReferences(refConfig.fields, [titleInput.value, descInput.value]);
1430
1773
  }
1431
1774
 
1432
1775
  /** The answer to send, or an empty list. */
@@ -1441,29 +1784,52 @@ function openPanel({ root, host, dock, client, onClose }) {
1441
1784
  const optional = refConfig.policy !== 'required';
1442
1785
 
1443
1786
  refsBox.hidden = false;
1787
+ // A handful of kinds read best as chips to tap; a long list goes back to
1788
+ // a select so the card does not turn into a wall of pills.
1789
+ const many = refConfig.fields.length > 6;
1444
1790
  refsBox.innerHTML = `
1445
- <p class="why">${escapeHtml(
1446
- optional
1447
- ? 'One more thing, so we can find your record — this helps us answer faster.'
1448
- : 'We need one of these to look this up.',
1449
- )}</p>
1791
+ <div class="ask-head">
1792
+ <span class="avatar" aria-hidden="true">${ICONS.sparkle}</span>
1793
+ <div>
1794
+ <strong>${optional ? 'One more thing' : 'We need one detail'}</strong>
1795
+ <p class="why">${escapeHtml(
1796
+ optional
1797
+ ? 'So we can find your record — this helps us answer faster.'
1798
+ : 'We need one of these to look this up.',
1799
+ )}</p>
1800
+ </div>
1801
+ </div>
1802
+ ${refConfig.fields.length > 1 && !many ? `
1803
+ <div class="chips" role="group" aria-label="Which detail you are giving">
1804
+ ${refConfig.fields.map((f) => `
1805
+ <button type="button" data-ref-kind="${escapeHtml(f.key)}" aria-pressed="${f.key === field.key}">${escapeHtml(f.label)}</button>
1806
+ `).join('')}
1807
+ </div>` : ''}
1450
1808
  <div class="row">
1451
- ${refConfig.fields.length > 1 ? `
1809
+ ${many ? `
1452
1810
  <select class="ref-kind" aria-label="Which detail you are giving">
1453
1811
  ${refConfig.fields.map((f) => `
1454
1812
  <option value="${escapeHtml(f.key)}" ${f.key === field.key ? 'selected' : ''}>${escapeHtml(f.label)}</option>
1455
1813
  `).join('')}
1456
1814
  </select>` : ''}
1457
- <input class="ref-value" type="${field.inputMode === 'email' ? 'email' : 'text'}"
1815
+ <input class="ref-value${refError ? ' invalid' : ''}" type="${field.inputMode === 'email' ? 'email' : 'text'}"
1458
1816
  inputmode="${escapeHtml(field.inputMode === 'numeric' || field.inputMode === 'tel' ? 'numeric' : 'text')}"
1459
1817
  value="${escapeHtml(refValue)}"
1460
- placeholder="${escapeHtml(field.placeholder || '')}"
1461
- aria-label="${escapeHtml(field.label)}" />
1818
+ placeholder="${escapeHtml(field.placeholder || field.label)}"
1819
+ aria-label="${escapeHtml(field.label)}" ${refError ? 'aria-invalid="true"' : ''} />
1462
1820
  </div>
1463
1821
  ${field.hint ? `<p class="hint">${escapeHtml(field.hint)}</p>` : ''}
1464
- ${refError ? `<p class="after err">${escapeHtml(refError)}</p>` : ''}
1465
- ${optional ? '<button type="button" class="link" data-act="ref-skip">I don\'t have this</button>' : ''}`;
1466
-
1822
+ ${refError ? `<p class="field-err" role="alert">${ICONS.alert}<span>${escapeHtml(refError)}</span></p>` : ''}
1823
+ ${optional ? '<button type="button" class="link" data-act="ref-skip">I don\'t have this — send without it</button>' : ''}`;
1824
+
1825
+ refsBox.querySelectorAll('[data-ref-kind]').forEach((chip) => {
1826
+ chip.addEventListener('click', () => {
1827
+ refChoice = chip.dataset.refKind;
1828
+ refError = '';
1829
+ refWarned = false;
1830
+ drawRefs();
1831
+ });
1832
+ });
1467
1833
  const value = refsBox.querySelector('.ref-value');
1468
1834
  value.addEventListener('input', () => {
1469
1835
  refValue = value.value;
@@ -1518,10 +1884,8 @@ function openPanel({ root, host, dock, client, onClose }) {
1518
1884
  sendBtn.addEventListener('click', async () => {
1519
1885
  const title = titleInput.value;
1520
1886
  if (!title.trim()) {
1521
- say('Please describe what went wrong.');
1522
- titleInput.classList.add('invalid');
1887
+ flagTitle('Add a few words about what went wrong — a short sentence is plenty.');
1523
1888
  titleInput.focus();
1524
- setTimeout(() => titleInput.classList.remove('invalid'), 500);
1525
1889
  return;
1526
1890
  }
1527
1891
 
@@ -1559,12 +1923,12 @@ function openPanel({ root, host, dock, client, onClose }) {
1559
1923
  say('');
1560
1924
  refreshSendState();
1561
1925
  const original = sendBtn.innerHTML;
1562
- sendBtn.innerHTML = '<span class="spinner"></span>Sending…';
1926
+ sendBtn.innerHTML = '<span class="spinner" aria-hidden="true"></span>Sending…';
1563
1927
 
1564
1928
  try {
1565
1929
  const res = await client.report({
1566
1930
  title,
1567
- description: $('#bc-desc').value,
1931
+ description: descInput.value,
1568
1932
  priority,
1569
1933
  references: answeredReference(),
1570
1934
  attachments: items
@@ -1811,7 +2175,7 @@ function openPanel({ root, host, dock, client, onClose }) {
1811
2175
  <input type="email" data-fld="email" value="${escapeHtml(pendingEmail)}"
1812
2176
  placeholder="you@example.com" autocomplete="email" spellcheck="false"
1813
2177
  aria-label="Your email address" ${signInBusy ? 'disabled' : ''} />
1814
- <button type="button" class="tool" data-act="code" ${signInBusy ? 'disabled' : ''}>
2178
+ <button type="button" class="primary" data-act="code" ${signInBusy ? 'disabled' : ''}>
1815
2179
  ${signInBusy ? 'Sending…' : 'Send code'}
1816
2180
  </button>
1817
2181
  </div>
@@ -1828,7 +2192,7 @@ function openPanel({ root, host, dock, client, onClose }) {
1828
2192
  <input type="text" class="code" data-fld="code" value="${escapeHtml(pendingCode)}"
1829
2193
  placeholder="123456" inputmode="numeric" autocomplete="one-time-code"
1830
2194
  maxlength="8" aria-label="The code we emailed you" ${signInBusy ? 'disabled' : ''} />
1831
- <button type="button" class="tool" data-act="confirm" ${signInBusy ? 'disabled' : ''}>
2195
+ <button type="button" class="primary" data-act="confirm" ${signInBusy ? 'disabled' : ''}>
1832
2196
  ${signInBusy ? 'Checking…' : 'Sign in'}
1833
2197
  </button>
1834
2198
  </div>
@@ -1962,7 +2326,7 @@ function openPanel({ root, host, dock, client, onClose }) {
1962
2326
  } catch (err) {
1963
2327
  sending = false;
1964
2328
  callBtn.disabled = false;
1965
- callBtn.textContent = 'Talk to the team';
2329
+ callBtn.innerHTML = `${ICONS.phone}Talk to the team`;
1966
2330
  refreshSendState();
1967
2331
  // A report filed with no call is still a report. Saying "the call
1968
2332
  // failed" and nothing else would leave the reporter thinking their
@@ -2112,16 +2476,12 @@ function openPanel({ root, host, dock, client, onClose }) {
2112
2476
  */
2113
2477
  async function showHistory() {
2114
2478
  const panel = scrim.querySelector('.panel');
2479
+ clearTimeout(assistantTimer);
2480
+ openReportId = null;
2481
+ panel.classList.remove('is-chat');
2115
2482
  panel.innerHTML = `
2116
- <div class="head">
2117
- <div class="mark">${ICONS.bug}</div>
2118
- <div class="titles">
2119
- <h2>Your reports</h2>
2120
- <p>Everything you've reported, and where each one got to.</p>
2121
- </div>
2122
- <button class="icon-btn" type="button" data-act="close" aria-label="Close">${ICONS.close}</button>
2123
- </div>
2124
- <div class="history"><div class="empty">Loading…</div></div>`;
2483
+ ${headHtml({ title: 'Your reports', sub: "Everything you've reported, and where each one got to." })}
2484
+ <div class="history" aria-busy="true">${skeletonRows(3)}</div>`;
2125
2485
 
2126
2486
  panel.querySelector('[data-act="close"]').addEventListener('click', close);
2127
2487
 
@@ -2129,8 +2489,8 @@ function openPanel({ root, host, dock, client, onClose }) {
2129
2489
  try {
2130
2490
  ({ reports } = await client.myReports());
2131
2491
  } catch {
2132
- panel.querySelector('.history').innerHTML =
2133
- '<div class="empty">Couldn\'t load your reports just now.</div>';
2492
+ const failed = panel.querySelector('.history');
2493
+ if (failed) failed.innerHTML = emptyHtml('Couldn\'t load your reports just now.', ICONS.alert);
2134
2494
  return;
2135
2495
  }
2136
2496
 
@@ -2138,16 +2498,19 @@ function openPanel({ root, host, dock, client, onClose }) {
2138
2498
  const list = panel.querySelector('.history');
2139
2499
  if (!list) return;
2140
2500
 
2501
+ list.removeAttribute('aria-busy');
2141
2502
  list.innerHTML = reports.length
2142
- ? reports.map((r) => `
2143
- <button class="row" type="button" data-report="${escapeHtml(r.id)}">
2503
+ ? reports.map((r, i) => `
2504
+ <button class="row" type="button" data-report="${escapeHtml(r.id)}" style="animation-delay:${Math.min(i, 8) * 30}ms">
2505
+ <span class="row-ico" aria-hidden="true">${ICONS.chat}</span>
2144
2506
  <span class="who">
2145
2507
  <span class="ttl">${escapeHtml(r.title)}</span>
2146
2508
  <span class="sub">${escapeHtml(r.reference)} · ${escapeHtml(relativeDay(r.createdAt))}</span>
2147
2509
  </span>
2148
2510
  <span class="pill ${escapeHtml(String(r.status).toLowerCase())}">${escapeHtml(statusWord(r.status))}</span>
2511
+ <span class="chev" aria-hidden="true">${ICONS.chevronRight}</span>
2149
2512
  </button>`).join('')
2150
- : '<div class="empty">You haven\'t reported anything yet.</div>';
2513
+ : emptyHtml('You haven\'t reported anything yet.', ICONS.inbox);
2151
2514
 
2152
2515
  for (const row of list.querySelectorAll('[data-report]')) {
2153
2516
  row.addEventListener('click', () => showReport(row.dataset.report));
@@ -2169,16 +2532,15 @@ function openPanel({ root, host, dock, client, onClose }) {
2169
2532
  */
2170
2533
  async function showReport(bugId, { fresh = false } = {}) {
2171
2534
  const panel = scrim.querySelector('.panel');
2535
+ clearTimeout(assistantTimer);
2536
+ panel.classList.add('is-chat');
2172
2537
  panel.innerHTML = `
2173
- <div class="head">
2174
- <button class="icon-btn back" type="button" data-act="back" aria-label="Back to your reports">${ICONS.back}</button>
2175
- <div class="titles">
2176
- <h2>${fresh ? 'Looking into it' : 'Your report'}</h2>
2177
- <p>${fresh ? 'Our assistant is investigating — it may ask you a question.' : 'Everything that has happened, and a way to answer.'}</p>
2178
- </div>
2179
- <button class="icon-btn" type="button" data-act="close" aria-label="Close">${ICONS.close}</button>
2180
- </div>
2181
- <div class="history"><div class="empty">Loading…</div></div>`;
2538
+ ${headHtml({
2539
+ back: true,
2540
+ title: fresh ? 'Looking into it' : 'Your report',
2541
+ sub: fresh ? 'Our assistant is on it — it may ask you a question here.' : 'Everything that has happened, and a way to answer.',
2542
+ })}
2543
+ <div class="history chatting" aria-busy="true"><div class="thread">${skeletonBubbles()}</div></div>`;
2182
2544
 
2183
2545
  panel.querySelector('[data-act="close"]').addEventListener('click', close);
2184
2546
  panel.querySelector('[data-act="back"]').addEventListener('click', showHistory);
@@ -2195,8 +2557,9 @@ function openPanel({ root, host, dock, client, onClose }) {
2195
2557
  if (!body || !panel.querySelector('[data-act="back"]')) return false;
2196
2558
 
2197
2559
  if (!report) {
2198
- if (fresh) return false;
2199
- body.innerHTML = '<div class="empty">Couldn\'t load that report just now.</div>';
2560
+ if (fresh) { panel.classList.remove('is-chat'); return false; }
2561
+ body.removeAttribute('aria-busy');
2562
+ body.innerHTML = emptyHtml('Couldn\'t load that report just now.', ICONS.alert);
2200
2563
  return false;
2201
2564
  }
2202
2565
 
@@ -2205,7 +2568,9 @@ function openPanel({ root, host, dock, client, onClose }) {
2205
2568
  // fixed" card up over the screen where they are reading that it was fixed
2206
2569
  // would be the notification arguing with itself.
2207
2570
  client.markReportRead(bugId);
2571
+ const drawn = threadHtml(report.timeline);
2208
2572
  body.classList.add('chatting');
2573
+ body.removeAttribute('aria-busy');
2209
2574
  body.innerHTML = `
2210
2575
  <div class="report-head">
2211
2576
  <span class="ttl">${escapeHtml(report.title)}</span>
@@ -2220,13 +2585,17 @@ function openPanel({ root, host, dock, client, onClose }) {
2220
2585
  ? `<span class="eta-line">Usually takes ${escapeHtml(String(report.estimate.label))}</span>`
2221
2586
  : ''}
2222
2587
  </div>
2223
- <div class="thread">${threadHtml(report.timeline)}</div>`;
2588
+ <div class="thread" role="log" aria-live="polite" aria-label="Conversation">${drawn}</div>`;
2224
2589
 
2225
2590
  const thread = body.querySelector('.thread');
2591
+ threadDrawn.set(thread, drawn);
2226
2592
  scrollToLatest(thread);
2227
2593
  mountVerification(body, report);
2228
2594
  if (report.canReply) mountComposer(body, thread, bugId);
2229
2595
  mountAssistant(body, report, bugId);
2596
+ // Again, now the cards above it have taken their share of the height: the
2597
+ // latest message — and any quick answers under it — is what they came for.
2598
+ scrollToLatest(thread);
2230
2599
  return true;
2231
2600
  }
2232
2601
 
@@ -2240,25 +2609,75 @@ function openPanel({ root, host, dock, client, onClose }) {
2240
2609
  function mountAssistant(body, report, bugId) {
2241
2610
  clearTimeout(assistantTimer);
2242
2611
  const a = report.assistant;
2243
- body.querySelector('.assistant')?.remove();
2244
- if (!a) return;
2245
- const card = document.createElement('div');
2612
+ const thread = body.querySelector('.thread');
2613
+ let card = body.querySelector('.assistant');
2614
+ if (!a) {
2615
+ card?.remove();
2616
+ thread?.querySelector('.typing-row')?.remove();
2617
+ return;
2618
+ }
2619
+
2620
+ // Updated in place rather than rebuilt: the card is polled every few
2621
+ // seconds while the assistant works, and a card that re-entered on every
2622
+ // poll would flicker — and re-announce itself to a screen reader — for
2623
+ // nothing. It only animates when the phase actually moves.
2624
+ const previous = card?.dataset.phase || null;
2625
+ if (!card) {
2626
+ card = document.createElement('div');
2627
+ card.setAttribute('role', 'status');
2628
+ card.setAttribute('aria-live', 'polite');
2629
+ body.querySelector('.report-head')?.after(card);
2630
+ }
2246
2631
  card.className = `assistant ${escapeHtml(a.phase)}`;
2247
- card.setAttribute('role', 'status');
2248
- card.setAttribute('aria-live', 'polite');
2249
- card.innerHTML = `
2250
- <div class="as-top">
2251
- <span class="as-orb" aria-hidden="true">${a.phase === 'analyzing' ? '<span class="as-spin"></span>' : '✦'}</span>
2252
- <div class="as-text">
2253
- <div class="as-title">${escapeHtml(a.title)}</div>
2254
- <div class="as-detail">${escapeHtml(a.detail)}</div>
2632
+ card.dataset.phase = a.phase;
2633
+
2634
+ const steps = a.steps || [];
2635
+ const done = steps.filter((st) => st.state === 'done').length;
2636
+ const active = steps.some((st) => st.state === 'active') ? 0.5 : 0;
2637
+ const pct = steps.length ? Math.round(((done + active) / steps.length) * 100) : 0;
2638
+ const content = `
2639
+ <div class="as-body${previous && previous !== a.phase ? ' entering' : ''}">
2640
+ <div class="as-top">
2641
+ <span class="as-orb" aria-hidden="true">${a.phase === 'analyzing' ? '<span class="as-spin"></span>'
2642
+ : a.phase === 'resolved' ? ICONS.check : ICONS.sparkle}</span>
2643
+ <div class="as-text">
2644
+ <div class="as-title">${escapeHtml(a.title)}</div>
2645
+ <div class="as-detail">${escapeHtml(a.detail)}</div>
2646
+ </div>
2255
2647
  </div>
2256
2648
  </div>
2257
- <ol class="as-steps">${(a.steps || []).map((st) => `<li class="${escapeHtml(st.state)}"><span></span>${escapeHtml(st.label)}</li>`).join('')}</ol>`;
2258
- body.querySelector('.report-head')?.after(card);
2649
+ ${steps.length ? `
2650
+ <div class="as-progress" role="progressbar" aria-label="Progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${pct}"><i style="width:${pct}%"></i></div>
2651
+ <ol class="as-steps">${steps.map((st) => `<li class="${escapeHtml(st.state)}"${st.state === 'active' ? ' aria-current="step"' : ''}><span class="dot"></span><span class="lbl">${escapeHtml(st.label)}</span></li>`).join('')}</ol>` : ''}`;
2652
+ const key = JSON.stringify([a.phase, a.title, a.detail, steps]);
2653
+ if (card.dataset.key !== key) {
2654
+ const bar = card.querySelector('.as-progress i');
2655
+ const from = bar?.style.width;
2656
+ card.innerHTML = content;
2657
+ card.dataset.key = key;
2658
+ // Let the bar travel from where it was to where it is now.
2659
+ const next = card.querySelector('.as-progress i');
2660
+ if (next && from) {
2661
+ next.style.width = from;
2662
+ requestAnimationFrame(() => { next.style.width = `${pct}%`; });
2663
+ }
2664
+ }
2665
+
2666
+ // While it works, a "thinking" bubble sits where its next message will land.
2667
+ if (thread) {
2668
+ const pinned = atLatest(thread);
2669
+ const typing = thread.querySelector('.typing-row');
2670
+ if (a.phase !== 'analyzing') typing?.remove();
2671
+ else if (!typing || typing !== thread.lastElementChild) {
2672
+ typing?.remove();
2673
+ thread.insertAdjacentHTML('beforeend', typingHtml());
2674
+ if (pinned) scrollToLatest(thread);
2675
+ }
2676
+ }
2677
+
2259
2678
  if (a.phase === 'analyzing') {
2260
2679
  assistantTimer = setTimeout(() => refreshThread(bugId), 3000);
2261
- } else if (a.phase === 'needs_info') {
2680
+ } else if (a.phase === 'needs_info' && previous !== 'needs_info') {
2262
2681
  body.querySelector('.composer textarea')?.focus();
2263
2682
  }
2264
2683
  }
@@ -2298,9 +2717,15 @@ function openPanel({ root, host, dock, client, onClose }) {
2298
2717
  if (!report || openReportId !== bugId) return;
2299
2718
 
2300
2719
  mountVerification(body, report);
2301
- mountAssistant(body, report, bugId);
2302
2720
  const pinned = atLatest(thread);
2303
- thread.innerHTML = threadHtml(report.timeline);
2721
+ const html = threadHtml(report.timeline);
2722
+ // Only rewritten when something changed, so a poll that brings nothing new
2723
+ // does not replay every bubble's entrance or re-announce the log.
2724
+ if (threadDrawn.get(thread) !== html) {
2725
+ thread.innerHTML = html;
2726
+ threadDrawn.set(thread, html);
2727
+ }
2728
+ mountAssistant(body, report, bugId);
2304
2729
  // Only follow the conversation down if they were already at the bottom.
2305
2730
  // Yanking someone back from something they scrolled up to read is worse
2306
2731
  // than making them scroll.
@@ -2503,7 +2928,7 @@ function openPanel({ root, host, dock, client, onClose }) {
2503
2928
  at: new Date().toISOString(),
2504
2929
  attachments: ready.map((f) => ({ type: 'FILE', name: f.name, size: f.size })),
2505
2930
  }, 'pending'));
2506
- scrollToLatest(thread);
2931
+ scrollToLatest(thread, { smooth: true });
2507
2932
 
2508
2933
  try {
2509
2934
  await sendMessage({
@@ -2653,14 +3078,38 @@ function openPanel({ root, host, dock, client, onClose }) {
2653
3078
  function threadHtml(timeline) {
2654
3079
  const entries = timeline || [];
2655
3080
  if (!entries.length) return '';
2656
- return entries.map((e) => (e.from ? messageHtml({
2657
- from: e.from,
2658
- author: e.reply?.author,
2659
- automated: e.reply?.automated,
2660
- body: e.reply?.body,
2661
- at: e.at,
2662
- attachments: e.reply?.attachments,
2663
- }) : systemHtml(e))).join('');
3081
+ let day = null;
3082
+ let prev = null;
3083
+ return entries.map((e) => {
3084
+ // A rule with the date on it whenever the calendar day turns, the way a
3085
+ // chat marks that the next message came later.
3086
+ const d = dayKey(e.at);
3087
+ const sep = d && d !== day ? `<div class="day-sep" role="separator">${escapeHtml(dayLabel(e.at))}</div>` : '';
3088
+ if (sep) { day = d; prev = null; }
3089
+ if (!e.from) { prev = null; return sep + systemHtml(e); }
3090
+ const message = {
3091
+ from: e.from,
3092
+ author: e.reply?.author,
3093
+ automated: e.reply?.automated,
3094
+ body: e.reply?.body,
3095
+ at: e.at,
3096
+ attachments: e.reply?.attachments,
3097
+ };
3098
+ // Consecutive messages from the same voice within a few minutes are one
3099
+ // turn: one avatar, one name, the bubbles stacked under it.
3100
+ const who = speakerOf(message);
3101
+ const cont = prev && prev.who === who
3102
+ && Math.abs(new Date(e.at).getTime() - new Date(prev.at).getTime()) < 5 * 60_000;
3103
+ prev = { who, at: e.at };
3104
+ return sep + messageHtml(message, cont ? 'cont' : '');
3105
+ }).join('');
3106
+ }
3107
+
3108
+ /** Which voice a message is in, for grouping: the reporter, the assistant, or a named person. */
3109
+ function speakerOf(message) {
3110
+ if (message.from === 'reporter') return 'me';
3111
+ if (message.automated) return 'assistant';
3112
+ return `team:${message.author || ''}`;
2664
3113
  }
2665
3114
 
2666
3115
  /**
@@ -2671,15 +3120,17 @@ function threadHtml(timeline) {
2671
3120
  * portal exactly rather than being a third attempt at the same sentence.
2672
3121
  */
2673
3122
  function systemHtml(entry) {
3123
+ const when = clockTime(entry.at);
2674
3124
  return `
2675
3125
  <div class="sys k-${escapeHtml(entry.kind)}">
2676
- <span class="dot" aria-hidden="true"></span>${escapeHtml(entry.summary || '')}
2677
- · ${escapeHtml(relativeDay(entry.at))}
3126
+ <span class="dot" aria-hidden="true"></span>${escapeHtml(entry.summary || '')}${when ? `
3127
+ <span class="t">· ${escapeHtml(when)}</span>` : ''}
2678
3128
  </div>`;
2679
3129
  }
2680
3130
 
2681
3131
  /**
2682
- * One bubble. `state` marks a message that is still in flight, or failed.
3132
+ * One bubble. `state` marks a message that is still in flight, or failed, or
3133
+ * one that continues the turn above it.
2683
3134
  *
2684
3135
  * A reply that was sent automatically says so, above the words rather than
2685
3136
  * after them. The person reading it is deciding whether their problem has been
@@ -2703,30 +3154,132 @@ function messageHtml(message, state = '') {
2703
3154
 
2704
3155
  // Named, or labelled, never both and never neither: an automated message has
2705
3156
  // no author to name, and naming one would be the lie the label exists to
2706
- // prevent.
3157
+ // prevent. The avatar says the same thing at a glance.
2707
3158
  let who = '';
2708
- if (automated) who = '<span class="msg-who auto">Automatic reply</span>';
2709
- else if (!mine && message.author) who = `<span class="msg-who">${escapeHtml(message.author)}</span>`;
3159
+ let avatar = '';
3160
+ if (automated) {
3161
+ who = '<span class="msg-who auto">Assistant <span class="tag">AI</span></span>';
3162
+ avatar = `<span class="av bot" aria-hidden="true">${ICONS.sparkle}</span>`;
3163
+ } else if (!mine) {
3164
+ const name = message.author || 'Support team';
3165
+ who = `<span class="msg-who">${escapeHtml(name)}</span>`;
3166
+ avatar = `<span class="av" aria-hidden="true">${escapeHtml(initials(name))}</span>`;
3167
+ }
3168
+ if (state.includes('cont')) who = '';
2710
3169
 
2711
3170
  // "Is this the problem you are facing?" — the confirm question the
2712
3171
  // investigation asks (bugcatch-workers check-loop). One tap answers it; the
2713
3172
  // buttons only show on the latest message, so an answered question has none.
2714
3173
  const confirm = automated && /reply "yes" or "no"/i.test(body)
2715
3174
  ? `<div class="quick" role="group" aria-label="Answer">
2716
- <button type="button" data-quick="Yes, this is the problem">Yes, that's it</button>
3175
+ <button type="button" data-quick="Yes, this is the problem">${ICONS.check}Yes, that's it</button>
2717
3176
  <button type="button" data-quick="No, that is not the problem">No, it's something else</button>
2718
3177
  </div>`
2719
3178
  : '';
2720
3179
 
2721
3180
  return `
2722
3181
  <div class="msg-row ${mine ? 'mine' : 'theirs'} ${automated ? 'auto' : ''} ${state}">
3182
+ ${avatar}
2723
3183
  ${who}
2724
3184
  <div class="bubble ${body ? '' : 'silent'}">${text}${files}</div>
2725
3185
  ${confirm}
2726
- <span class="msg-when">${escapeHtml(relativeDay(message.at))}</span>
3186
+ <span class="msg-when">${escapeHtml(clockTime(message.at) || relativeDay(message.at))}</span>
3187
+ </div>`;
3188
+ }
3189
+
3190
+ /** The assistant, thinking: where its next message will appear. */
3191
+ function typingHtml() {
3192
+ return `
3193
+ <div class="typing-row">
3194
+ <span class="av bot" aria-hidden="true">${ICONS.sparkle}</span>
3195
+ <span class="msg-who auto">Assistant <span class="tag">AI</span></span>
3196
+ <div class="bubble typing" role="img" aria-label="The assistant is working on it"><i></i><i></i><i></i></div>
3197
+ </div>`;
3198
+ }
3199
+
3200
+ /**
3201
+ * The panel's header, on every screen. The assistant's avatar carries the
3202
+ * identity; `back` adds the way out of a report to the list of them.
3203
+ */
3204
+ function headHtml({ title, sub, id = '', back = false }) {
3205
+ return `
3206
+ <div class="head">
3207
+ ${back ? `<button class="icon-btn back" type="button" data-act="back" aria-label="Back to your reports">${ICONS.back}</button>` : ''}
3208
+ <div class="mark" aria-hidden="true">${ICONS.sparkle}</div>
3209
+ <div class="titles">
3210
+ <h2${id ? ` id="${id}"` : ''}>${escapeHtml(title)}</h2>
3211
+ <p>${escapeHtml(sub)}</p>
3212
+ </div>
3213
+ <button class="icon-btn" type="button" data-act="close" aria-label="Close">${ICONS.close}</button>
2727
3214
  </div>`;
2728
3215
  }
2729
3216
 
3217
+ function emptyHtml(text, icon) {
3218
+ return `<div class="empty"><span class="empty-ico" aria-hidden="true">${icon}</span>${escapeHtml(text)}</div>`;
3219
+ }
3220
+
3221
+ /** Placeholder rows while the list loads — the shape of what is coming. */
3222
+ function skeletonRows(n) {
3223
+ return Array.from({ length: n }, () => `
3224
+ <div class="row" aria-hidden="true">
3225
+ <span class="row-ico"></span>
3226
+ <span class="who"><span class="skel" style="width:70%"></span><span class="skel" style="width:40%;height:9px"></span></span>
3227
+ </div>`).join('') + '<span class="sr-only">Loading…</span>';
3228
+ }
3229
+
3230
+ function skeletonBubbles() {
3231
+ return `
3232
+ <div aria-hidden="true" style="display:flex;flex-direction:column;gap:10px;padding-top:6px">
3233
+ <span class="skel" style="width:42%;height:34px;border-radius:16px;align-self:center"></span>
3234
+ <span class="skel" style="width:66%;height:52px;border-radius:18px"></span>
3235
+ <span class="skel" style="width:48%;height:38px;border-radius:18px;align-self:flex-end"></span>
3236
+ </div>
3237
+ <span class="sr-only">Loading…</span>`;
3238
+ }
3239
+
3240
+ // What each open thread was last drawn from, so a poll that brings nothing new
3241
+ // leaves the DOM (and the reader's place in it) alone.
3242
+ const threadDrawn = new WeakMap();
3243
+
3244
+ function initials(name) {
3245
+ const parts = String(name).trim().split(/\s+/).filter(Boolean);
3246
+ if (!parts.length) return '?';
3247
+ return (parts[0][0] + (parts.length > 1 ? parts[parts.length - 1][0] : '')).toUpperCase();
3248
+ }
3249
+
3250
+ function dayKey(iso) {
3251
+ const d = new Date(iso);
3252
+ return Number.isFinite(d.getTime()) ? `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}` : null;
3253
+ }
3254
+
3255
+ /** "Today" / "Yesterday" / "Mon, 14 Sep" — the label on a day separator. */
3256
+ function dayLabel(iso) {
3257
+ const d = new Date(iso);
3258
+ const today = new Date();
3259
+ const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1);
3260
+ if (dayKey(d) === dayKey(today)) return 'Today';
3261
+ if (dayKey(d) === dayKey(yesterday)) return 'Yesterday';
3262
+ try {
3263
+ return d.toLocaleDateString(undefined, {
3264
+ weekday: 'short', day: 'numeric', month: 'short',
3265
+ ...(d.getFullYear() !== today.getFullYear() ? { year: 'numeric' } : {}),
3266
+ });
3267
+ } catch {
3268
+ return relativeDay(iso);
3269
+ }
3270
+ }
3271
+
3272
+ /** "14:05" or "2:05 PM", in the reader's own convention. */
3273
+ function clockTime(iso) {
3274
+ const d = new Date(iso);
3275
+ if (!Number.isFinite(d.getTime())) return '';
3276
+ try {
3277
+ return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
3278
+ } catch {
3279
+ return '';
3280
+ }
3281
+ }
3282
+
2730
3283
  /**
2731
3284
  * One attachment, rendered as what it is.
2732
3285
  *
@@ -2761,8 +3314,10 @@ function atLatest(thread) {
2761
3314
  return thread.scrollHeight - thread.scrollTop - thread.clientHeight < 40;
2762
3315
  }
2763
3316
 
2764
- function scrollToLatest(thread) {
2765
- thread.scrollTop = thread.scrollHeight;
3317
+ function scrollToLatest(thread, { smooth = false } = {}) {
3318
+ const calm = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
3319
+ if (smooth && !calm && thread.scrollTo) thread.scrollTo({ top: thread.scrollHeight, behavior: 'smooth' });
3320
+ else thread.scrollTop = thread.scrollHeight;
2766
3321
  }
2767
3322
 
2768
3323
  /** Status as a word a reporter would use, not the enum the database stores. */