@sphoro/bugcatch-web-sdk 0.14.3 → 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/README.md +390 -115
- package/dist/bugcatch.es.js +2633 -1796
- package/dist/bugcatch.es.js.map +1 -1
- package/dist/bugcatch.umd.js +786 -426
- package/dist/bugcatch.umd.js.map +1 -1
- package/package.json +1 -1
- package/src/identity.js +277 -0
- package/src/index.d.ts +145 -7
- package/src/index.js +243 -28
- package/src/widget.js +1019 -394
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: #
|
|
111
|
+
--bc-muted: #5f6673;
|
|
101
112
|
--bc-faint: #9aa1ad;
|
|
102
|
-
--bc-border: #
|
|
103
|
-
--bc-subtle: #
|
|
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
|
|
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: #
|
|
122
|
-
--bc-faint: #
|
|
123
|
-
--bc-border: #
|
|
124
|
-
--bc-subtle: #
|
|
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
|
|
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: #
|
|
136
|
-
--bc-faint: #
|
|
137
|
-
--bc-border: #
|
|
138
|
-
--bc-subtle: #
|
|
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
|
|
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(
|
|
167
|
-
@keyframes bc-panel-out { to { opacity: 0; transform: translateY(
|
|
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(.
|
|
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,.
|
|
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
|
-
|
|
214
|
-
|
|
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:
|
|
262
|
+
font-size: var(--bc-fs-md); font-weight: 620; line-height: 1; letter-spacing: -.01em;
|
|
217
263
|
cursor: pointer;
|
|
218
|
-
box-shadow: 0
|
|
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
|
-
|
|
223
|
-
|
|
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
|
|
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,.
|
|
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
|
|
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
|
-
|
|
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
|
|
301
|
+
overflow: hidden;
|
|
244
302
|
background: var(--bc-bg); color: var(--bc-fg);
|
|
245
|
-
border-radius:
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
|
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
|
-
|
|
255
|
-
.head
|
|
256
|
-
flex: none;
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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 {
|
|
263
|
-
|
|
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:
|
|
267
|
-
border: none; border-radius:
|
|
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-
|
|
271
|
-
.icon-btn:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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:
|
|
408
|
+
.known { margin-bottom: 16px; }
|
|
299
409
|
.known .known-h {
|
|
300
|
-
margin: 0 0 8px; font-size:
|
|
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:
|
|
414
|
+
display: flex; align-items: center; gap: 10px;
|
|
305
415
|
padding: 10px 12px; margin-bottom: 6px;
|
|
306
|
-
background: var(--bc-subtle); border: 1px solid
|
|
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:
|
|
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:
|
|
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-
|
|
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 .
|
|
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:
|
|
338
|
-
font-size:
|
|
339
|
-
background: var(--bc-subtle); border-radius:
|
|
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
|
-
.
|
|
345
|
-
padding: 0; font-size:
|
|
346
|
-
color: var(--bc-accent); background: none; border: none; cursor: pointer;
|
|
347
|
-
}
|
|
348
|
-
.
|
|
349
|
-
.
|
|
350
|
-
.whoami .
|
|
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:
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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-
|
|
377
|
-
border: 1px solid var(--bc-border); border-radius:
|
|
378
|
-
}
|
|
379
|
-
.refs select:focus {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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-
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
426
|
-
border-radius:
|
|
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:
|
|
439
|
-
width:
|
|
440
|
-
background: rgba(10,12,16,.
|
|
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,333 +586,533 @@ textarea { min-height: 84px; resize: vertical; }
|
|
|
463
586
|
|
|
464
587
|
/* ---------- messages ---------- */
|
|
465
588
|
.msg {
|
|
466
|
-
display: flex; align-items: flex-start; gap:
|
|
467
|
-
font-size:
|
|
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
|
|
471
|
-
.msg.
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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:
|
|
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
|
-
.
|
|
487
|
-
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
button.ghost
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
654
|
+
overflow: hidden; display: flex; flex-direction: column; padding: 0;
|
|
515
655
|
}
|
|
516
656
|
.history .row {
|
|
517
|
-
display: flex; align-items:
|
|
518
|
-
padding:
|
|
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:
|
|
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:
|
|
527
|
-
.history .
|
|
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;
|
|
688
|
+
width: 100%; background: none; border: 0; margin-bottom: 2px;
|
|
532
689
|
font: inherit; color: inherit; text-align: left; cursor: pointer;
|
|
533
|
-
border-radius:
|
|
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:
|
|
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
|
-
|
|
541
|
-
|
|
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:
|
|
544
|
-
.report-head .sub { font-size:
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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;
|
|
554
715
|
}
|
|
555
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);
|
|
721
|
+
}
|
|
722
|
+
.day-sep::before, .day-sep::after { content: ''; flex: 1; height: 1px; background: var(--bc-border); }
|
|
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
|
-
|
|
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
|
|
578
|
-
the arrangement every chat has taught people to
|
|
579
|
-
|
|
580
|
-
.msg-row
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
|
|
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:
|
|
595
|
-
}
|
|
596
|
-
.msg-row.theirs .bubble { border-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
so it reads the same to someone who cannot see the
|
|
600
|
-
.msg-row.auto .bubble { border-
|
|
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
|
|
606
|
-
.msg-row.pending { opacity: .
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
|
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; }
|
|
869
|
+
@keyframes as-rot { to { transform: rotate(360deg); } }
|
|
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; }
|
|
898
|
+
@keyframes as-pulse { 50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--bc-accent) 25%, transparent); } }
|
|
899
|
+
@media (prefers-reduced-motion: reduce) { .as-spin, .as-steps li.active .dot, .assistant.analyzing .as-progress i, .typing i { animation: none; } }
|
|
900
|
+
|
|
626
901
|
/* Quick answers under the confirm question — only on the latest message. */
|
|
627
|
-
.msg-row .quick { display: none; gap: 6px; flex-wrap: wrap; margin-top:
|
|
902
|
+
.msg-row .quick { display: none; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
|
|
628
903
|
.msg-row:last-child .quick { display: flex; }
|
|
629
904
|
.quick button {
|
|
630
|
-
|
|
631
|
-
|
|
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;
|
|
632
910
|
}
|
|
633
|
-
.quick button:
|
|
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); }
|
|
634
916
|
|
|
635
917
|
/* ---------- the composer ---------- */
|
|
636
918
|
.composer {
|
|
637
|
-
|
|
638
|
-
padding: 10px
|
|
919
|
+
flex: none; position: relative;
|
|
920
|
+
padding: 10px 12px max(12px, env(safe-area-inset-bottom, 0px));
|
|
639
921
|
/* The panel's own background, not --bc-elevated: the two are the same white
|
|
640
922
|
in light mode but differ in dark, where a raised composer reads as a band
|
|
641
923
|
laid over the panel rather than the foot of it. */
|
|
642
924
|
background: var(--bc-bg);
|
|
643
925
|
border-top: 1px solid var(--bc-border);
|
|
644
926
|
}
|
|
645
|
-
|
|
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); }
|
|
646
936
|
.composer textarea {
|
|
647
|
-
flex: 1; min-height:
|
|
648
|
-
font: inherit; font-size:
|
|
649
|
-
color: var(--bc-fg); background:
|
|
650
|
-
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;
|
|
651
940
|
}
|
|
652
|
-
.composer textarea:focus {
|
|
653
|
-
.composer .tools { display: flex; gap:
|
|
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; }
|
|
654
943
|
.composer .tool {
|
|
655
|
-
width: 34px; height: 34px; display: grid; place-items: center; cursor: pointer;
|
|
656
|
-
border-radius:
|
|
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;
|
|
657
946
|
color: var(--bc-muted);
|
|
658
947
|
}
|
|
659
|
-
.composer .tool
|
|
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); }
|
|
660
951
|
.composer .tool:disabled { opacity: .4; cursor: default; }
|
|
661
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); }
|
|
662
954
|
.composer .send {
|
|
663
|
-
width: 34px; height: 34px; flex: none; border-radius:
|
|
664
|
-
background: var(--bc-accent); border:
|
|
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);
|
|
665
957
|
display: grid; place-items: center;
|
|
958
|
+
transition: transform 160ms var(--bc-ease), opacity 160ms, filter 160ms;
|
|
666
959
|
}
|
|
667
|
-
.composer .send:disabled {
|
|
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); }
|
|
668
962
|
.composer .staged { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 0 8px; }
|
|
669
963
|
.composer .chip {
|
|
670
|
-
display: flex; align-items: center; gap: 6px; max-width:
|
|
671
|
-
padding: 5px 7px; border-radius:
|
|
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);
|
|
672
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;
|
|
673
968
|
}
|
|
674
|
-
.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); }
|
|
675
970
|
.composer .chip button {
|
|
676
|
-
flex: none; width:
|
|
677
|
-
border: 0; border-radius:
|
|
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;
|
|
678
973
|
}
|
|
679
974
|
.composer .chip button:hover { color: var(--bc-danger); }
|
|
680
975
|
.composer .chip.uploading { border-style: dashed; }
|
|
681
|
-
.composer .note { padding: 6px
|
|
976
|
+
.composer .note { padding: 6px 6px 0; font-size: var(--bc-fs-xs); color: var(--bc-muted); }
|
|
682
977
|
.composer .note.err { color: var(--bc-danger); }
|
|
683
978
|
.reccount { font-variant-numeric: tabular-nums; }
|
|
684
979
|
|
|
685
980
|
/* Status carries the only colour in this list, so it is the thing the eye
|
|
686
981
|
lands on — which is what someone opening this screen came to check. */
|
|
687
982
|
.pill {
|
|
688
|
-
flex: none;
|
|
689
|
-
|
|
690
|
-
|
|
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; }
|
|
691
1000
|
}
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
.
|
|
695
|
-
|
|
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; }
|
|
696
1019
|
|
|
697
1020
|
/* A call in progress, in place of the form.
|
|
698
1021
|
---------------------------------------------------------------------------
|
|
699
1022
|
Deliberately not styled as a chat. Nothing arrives here to read — the
|
|
700
1023
|
conversation is happening out loud — so this is a state, a line of
|
|
701
1024
|
reassurance about the report, and a way to hang up. */
|
|
702
|
-
.call { padding:
|
|
703
|
-
.call-head
|
|
704
|
-
.call-head
|
|
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; }
|
|
705
1029
|
.call-dot {
|
|
706
|
-
width:
|
|
1030
|
+
width: 9px; height: 9px; border-radius: 50%; background: var(--bc-accent);
|
|
707
1031
|
animation: bc-call-pulse 1.6s ease-in-out infinite;
|
|
708
1032
|
}
|
|
709
1033
|
.call-dot.off { background: var(--bc-faint); animation: none; }
|
|
710
1034
|
@keyframes bc-call-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } }
|
|
711
1035
|
/* Said while it is still ringing, because that is when somebody is deciding
|
|
712
1036
|
whether it is worth waiting. */
|
|
713
|
-
.call-note { margin: 0 0
|
|
1037
|
+
.call-note { margin: 0 0 18px; font-size: 13px; color: var(--bc-muted); line-height: 1.55; }
|
|
714
1038
|
|
|
715
1039
|
/* ---------- the same call, on top of the report it is about ---------- */
|
|
716
1040
|
/* A bar rather than its own screen, unlike the one the composer opens. The
|
|
717
1041
|
thread underneath is exactly what both people are talking about, so taking it
|
|
718
1042
|
off screen would be taking away the only shared reference. */
|
|
719
1043
|
.livecall {
|
|
1044
|
+
flex: none;
|
|
720
1045
|
display: flex; align-items: center; gap: 9px;
|
|
721
|
-
margin: 12px 0
|
|
722
|
-
border: 1px solid var(--bc-
|
|
723
|
-
background: var(--bc-
|
|
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;
|
|
724
1050
|
}
|
|
725
1051
|
.livecall .who { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
726
1052
|
.livecall .secs { font-variant-numeric: tabular-nums; color: var(--bc-muted); }
|
|
727
1053
|
.livecall .act {
|
|
728
|
-
padding: 5px
|
|
729
|
-
background: var(--bc-
|
|
730
|
-
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;
|
|
731
1057
|
}
|
|
732
1058
|
.livecall .act:hover { background: var(--bc-subtle); }
|
|
733
1059
|
.livecall .act.danger { border-color: transparent; background: var(--bc-danger); color: #fff; }
|
|
734
|
-
.livecall.over { color: var(--bc-muted); }
|
|
1060
|
+
.livecall.over { color: var(--bc-muted); background: var(--bc-subtle); border-color: var(--bc-border); }
|
|
735
1061
|
.call-note code { font-size: 11px; }
|
|
736
1062
|
@media (prefers-reduced-motion: reduce) { .call-dot { animation: none; } }
|
|
737
1063
|
|
|
738
|
-
.done
|
|
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; }
|
|
739
1067
|
.done .tick {
|
|
740
|
-
width:
|
|
741
|
-
display: grid; place-items: center; font-size:
|
|
1068
|
+
width: 56px; height: 56px; margin: 0 auto 16px; border-radius: 50%;
|
|
1069
|
+
display: grid; place-items: center; font-size: 26px;
|
|
742
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);
|
|
743
1072
|
animation: bc-pop 420ms var(--bc-ease) both;
|
|
744
1073
|
}
|
|
745
|
-
.done h3 { margin: 0 0
|
|
746
|
-
.done p { margin: 0; font-size:
|
|
747
|
-
.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); }
|
|
748
1077
|
/* The likely wait. Set apart from the reference line above it by a rule rather
|
|
749
1078
|
than by weight: one is bookkeeping and the other is the answer to the
|
|
750
1079
|
question the reporter actually has, and they should not read as one
|
|
751
1080
|
paragraph. */
|
|
752
1081
|
.done .eta {
|
|
753
|
-
margin:
|
|
754
|
-
padding-top:
|
|
755
|
-
border-top: 1px solid var(--bc-
|
|
1082
|
+
margin: 14px auto 0;
|
|
1083
|
+
padding-top: 12px;
|
|
1084
|
+
border-top: 1px solid var(--bc-border);
|
|
756
1085
|
max-width: 30ch;
|
|
757
1086
|
line-height: 1.5;
|
|
758
1087
|
}
|
|
759
|
-
.done .eta strong { color: var(--bc-
|
|
1088
|
+
.done .eta strong { color: var(--bc-fg); font-weight: 620; }
|
|
760
1089
|
/* The same figure on the report the reporter came back to. Sits under the
|
|
761
1090
|
status pill in the header block, muted — it is context, not the headline the
|
|
762
1091
|
status is. */
|
|
763
1092
|
.report-head .eta-line {
|
|
764
1093
|
grid-column: 1 / -1;
|
|
765
|
-
font-size:
|
|
1094
|
+
font-size: var(--bc-fs-xs);
|
|
766
1095
|
color: var(--bc-muted);
|
|
767
1096
|
margin-top: 2px;
|
|
768
1097
|
}
|
|
769
1098
|
|
|
770
1099
|
/* ---------- recording bar ---------- */
|
|
771
1100
|
.recbar {
|
|
772
|
-
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%);
|
|
773
1102
|
z-index: 2147483002;
|
|
774
1103
|
display: flex; align-items: center; gap: 12px;
|
|
775
|
-
padding:
|
|
1104
|
+
padding: 8px 8px 8px 16px; border-radius: 999px;
|
|
776
1105
|
background: var(--bc-elevated); color: var(--bc-fg);
|
|
777
1106
|
border: 1px solid var(--bc-border); box-shadow: var(--bc-shadow);
|
|
778
1107
|
animation: bc-item-in 300ms var(--bc-ease) both;
|
|
779
1108
|
}
|
|
780
1109
|
.recbar .dot { width: 9px; height: 9px; border-radius: 50%; background: var(--bc-danger); animation: bc-pulse 1.4s ease-in-out infinite; }
|
|
781
|
-
.recbar .time { font-variant-numeric: tabular-nums; font-size: 13px; font-weight:
|
|
1110
|
+
.recbar .time { font-variant-numeric: tabular-nums; font-size: 13px; font-weight: 650; min-width: 38px; }
|
|
782
1111
|
.recbar .hint { font-size: 12px; color: var(--bc-muted); }
|
|
783
|
-
.recbar button { padding:
|
|
1112
|
+
.recbar button { height: 34px; padding: 0 14px; font-size: 12.5px; font-weight: 620; border-radius: 999px; cursor: pointer; }
|
|
784
1113
|
.recbar .stop { background: var(--bc-danger); border: 1px solid var(--bc-danger); color: #fff; }
|
|
785
1114
|
.recbar .stop:hover { filter: brightness(1.08); }
|
|
786
1115
|
|
|
787
|
-
@media (max-width: 520px) {
|
|
788
|
-
.panel { width: calc(100vw - 24px); max-width: none; }
|
|
789
|
-
.scrim.at-right .panel, .scrim.at-left .panel { left: 12px; right: 12px; }
|
|
790
|
-
.recbar .hint { display: none; }
|
|
791
|
-
}
|
|
792
|
-
|
|
793
1116
|
/* ---------- phones ----------------------------------------------------------
|
|
794
1117
|
This widget runs inside a shadow root on somebody else's site, so its own
|
|
795
1118
|
stylesheet is the whole story: the host page cannot correct any of this, and
|
|
@@ -814,6 +1137,32 @@ button.primary .spinner { border-color: rgba(255,255,255,.4); border-top-color:
|
|
|
814
1137
|
padding-left: env(safe-area-inset-left, 0px);
|
|
815
1138
|
}
|
|
816
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
|
+
|
|
817
1166
|
@media (pointer: coarse) {
|
|
818
1167
|
/* iOS zooms the whole page when a field it focuses is under 16px, and does
|
|
819
1168
|
not zoom back out. On the host's site that is not the widget's page to
|
|
@@ -825,47 +1174,33 @@ button.primary .spinner { border-color: rgba(255,255,255,.4); border-top-color:
|
|
|
825
1174
|
/* 44px is the floor a thumb hits reliably. Every control in this form was
|
|
826
1175
|
under it, on the one surface in the product designed for someone who is
|
|
827
1176
|
not an operator and gets no second attempt. */
|
|
828
|
-
.
|
|
829
|
-
.
|
|
830
|
-
.
|
|
831
|
-
|
|
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; }
|
|
832
1181
|
textarea { min-height: 104px; }
|
|
1182
|
+
.composer textarea { min-height: 40px; }
|
|
833
1183
|
|
|
834
|
-
/* The theme switch and the close button are
|
|
1184
|
+
/* The theme switch and the close button are small glyphs whose boxes cannot
|
|
835
1185
|
grow without moving the header text beside them, so each keeps its size
|
|
836
1186
|
and gets an invisible target drawn around it instead. */
|
|
837
|
-
.themer button, .head .icon-btn, .thumb .
|
|
838
|
-
.
|
|
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 {
|
|
839
1190
|
content: '';
|
|
840
1191
|
position: absolute;
|
|
841
|
-
inset: -
|
|
1192
|
+
inset: -8px;
|
|
842
1193
|
}
|
|
843
|
-
/* The close button is the one people reach for in a hurry, and it sits in
|
|
844
|
-
the corner where a thumb is least accurate. */
|
|
845
|
-
.head .icon-btn::after { inset: -12px; }
|
|
846
1194
|
/* The themer's three buttons sit side by side, so their expanders would
|
|
847
1195
|
overlap each other without a gap to grow into. */
|
|
848
1196
|
.themer { gap: 4px; }
|
|
849
|
-
|
|
850
|
-
/* Thumb-sized controls make the form taller, which on a short phone pushed
|
|
851
|
-
"Send report" below the fold of a panel that scrolls — so the one button
|
|
852
|
-
the whole widget exists for was reachable only by scrolling past the
|
|
853
|
-
attachment row. It stays on screen instead. */
|
|
854
|
-
.foot {
|
|
855
|
-
position: sticky;
|
|
856
|
-
bottom: 0;
|
|
857
|
-
margin-top: 16px;
|
|
858
|
-
/* The panel scrolls under this, so the bar cannot be translucent. */
|
|
859
|
-
background: var(--bc-subtle);
|
|
860
|
-
padding-bottom: max(14px, env(safe-area-inset-bottom, 0px));
|
|
861
|
-
}
|
|
862
1197
|
}
|
|
863
1198
|
|
|
864
1199
|
/* Hover is a lie on a touchscreen: it latches on tap and stays. The launcher's
|
|
865
1200
|
lift in particular was left applied after the panel closed. */
|
|
866
1201
|
@media (hover: none) {
|
|
867
|
-
.launcher:hover { transform: none; filter: none;
|
|
868
|
-
.tool:hover, .themer button:hover {
|
|
1202
|
+
.launcher:hover { transform: none; filter: none; }
|
|
1203
|
+
.tool:hover:not(:disabled), .themer button:hover { transform: none; }
|
|
869
1204
|
}
|
|
870
1205
|
`;
|
|
871
1206
|
|
|
@@ -913,7 +1248,8 @@ export function mountWidget(client) {
|
|
|
913
1248
|
const launcher = document.createElement('button');
|
|
914
1249
|
launcher.className = 'launcher';
|
|
915
1250
|
launcher.type = 'button';
|
|
916
|
-
launcher.innerHTML =
|
|
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>`;
|
|
917
1253
|
launcher.setAttribute('aria-haspopup', 'dialog');
|
|
918
1254
|
launcher.setAttribute('aria-expanded', 'false');
|
|
919
1255
|
|
|
@@ -941,7 +1277,14 @@ export function mountWidget(client) {
|
|
|
941
1277
|
root.appendChild(dock);
|
|
942
1278
|
|
|
943
1279
|
document.body.appendChild(host);
|
|
944
|
-
|
|
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; };
|
|
945
1288
|
}
|
|
946
1289
|
|
|
947
1290
|
/** Normalises a configured theme to one of the three the switch offers. */
|
|
@@ -977,26 +1320,25 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
977
1320
|
scrim.style.setProperty('--bc-dock-h', `${Math.round(dock.getBoundingClientRect().height)}px`);
|
|
978
1321
|
scrim.innerHTML = `
|
|
979
1322
|
<div class="panel" role="dialog" aria-modal="true" aria-labelledby="bc-h">
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
</div>
|
|
986
|
-
<button class="icon-btn" type="button" data-act="close" aria-label="Close">${ICONS.close}</button>
|
|
987
|
-
</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
|
+
})}
|
|
988
1328
|
<div class="body">
|
|
989
|
-
<div class="slot"></div>
|
|
1329
|
+
<div class="slot" role="alert"></div>
|
|
990
1330
|
<div class="whoami" hidden></div>
|
|
991
1331
|
<div class="known" hidden></div>
|
|
992
1332
|
<div class="compose">
|
|
993
1333
|
<div class="field">
|
|
994
1334
|
<label for="bc-title">What went wrong?</label>
|
|
995
|
-
<input id="bc-title" type="text" placeholder="
|
|
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>
|
|
996
1338
|
</div>
|
|
997
1339
|
<div class="field">
|
|
998
|
-
<label for="bc-desc">
|
|
999
|
-
<textarea id="bc-desc" placeholder="
|
|
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>
|
|
1000
1342
|
</div>
|
|
1001
1343
|
<!-- Drawn only when this project asks for an identifier and the report
|
|
1002
1344
|
does not already carry one. Between the description and the
|
|
@@ -1004,7 +1346,7 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1004
1346
|
it is a question about what they just typed. -->
|
|
1005
1347
|
<div class="refs" hidden></div>
|
|
1006
1348
|
<div class="field">
|
|
1007
|
-
<label id="bc-prio-l">
|
|
1349
|
+
<label id="bc-prio-l">How much is it getting in your way?</label>
|
|
1008
1350
|
<div class="seg" role="group" aria-labelledby="bc-prio-l">
|
|
1009
1351
|
${PRIORITIES.map((p) => `
|
|
1010
1352
|
<button type="button" data-prio="${p.value}" aria-pressed="${p.value === priority}">${p.label}</button>
|
|
@@ -1013,11 +1355,14 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1013
1355
|
</div>
|
|
1014
1356
|
${canCapture ? `
|
|
1015
1357
|
<div class="field">
|
|
1016
|
-
<
|
|
1017
|
-
|
|
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">
|
|
1018
1363
|
${canShoot ? `<button class="tool" type="button" data-act="shot">${ICONS.camera}Screenshot</button>` : ''}
|
|
1019
|
-
${canRecord ? `<button class="tool" type="button" data-act="rec">${ICONS.video}Record</button>` : ''}
|
|
1020
|
-
<button class="tool" type="button" data-act="file">${ICONS.paperclip}
|
|
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>
|
|
1021
1366
|
</div>
|
|
1022
1367
|
<div class="thumbs"></div>
|
|
1023
1368
|
<input type="file" accept="${REPORT_MIMES.join(',')}" multiple hidden />
|
|
@@ -1025,22 +1370,26 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1025
1370
|
</div>
|
|
1026
1371
|
</div>
|
|
1027
1372
|
<div class="foot">
|
|
1028
|
-
<
|
|
1029
|
-
<
|
|
1373
|
+
<span class="note" aria-live="polite"></span>
|
|
1374
|
+
${client.hasReports() ? '<button class="quiet" type="button" data-act="history">Your reports</button>' : ''}
|
|
1030
1375
|
<span class="spacer"></span>
|
|
1031
|
-
${client.hasReports() ? '<button class="ghost" type="button" data-act="history">Your reports</button>' : ''}
|
|
1032
|
-
<button class="ghost" type="button" data-act="cancel">Cancel</button>
|
|
1033
1376
|
<!-- Hidden until the API confirms this project can take a call, which
|
|
1034
1377
|
most cannot. Drawn here rather than as a second launcher because it
|
|
1035
1378
|
is the same errand as Send report — the difference is only whether
|
|
1036
1379
|
the reporter would rather say it than type it. -->
|
|
1037
|
-
<button class="ghost" type="button" data-act="call" hidden
|
|
1038
|
-
<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>
|
|
1039
1385
|
</div>
|
|
1040
1386
|
</div>`;
|
|
1041
1387
|
|
|
1042
1388
|
const $ = (sel) => scrim.querySelector(sel);
|
|
1043
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');
|
|
1044
1393
|
const slot = $('.slot');
|
|
1045
1394
|
const thumbs = $('.thumbs');
|
|
1046
1395
|
const filePicker = scrim.querySelector('input[type=file]');
|
|
@@ -1065,8 +1414,8 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1065
1414
|
function persistDraft() {
|
|
1066
1415
|
clearTimeout(draftTimer);
|
|
1067
1416
|
if (!draftLoaded || draftSubmitted || cfg.drafts === false) return draftWrites;
|
|
1068
|
-
const value = titleInput.value ||
|
|
1069
|
-
title: titleInput.value, description:
|
|
1417
|
+
const value = titleInput.value || descInput.value || items.length ? {
|
|
1418
|
+
title: titleInput.value, description: descInput.value, priority,
|
|
1070
1419
|
items: items.map(({ blob, mime, kind }) => ({ blob, mime, kind })),
|
|
1071
1420
|
} : null;
|
|
1072
1421
|
draftWrites = draftWrites.catch(() => {}).then(() => draftOperation(savedDraftKey, value))
|
|
@@ -1086,10 +1435,10 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1086
1435
|
compose.inert = true;
|
|
1087
1436
|
readDraft(savedDraftKey).then((draft) => {
|
|
1088
1437
|
if (closed || !draft) return;
|
|
1089
|
-
draftStatus.innerHTML = '
|
|
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>';
|
|
1090
1439
|
draftStatus.querySelector('[data-resume]').onclick = () => {
|
|
1091
1440
|
titleInput.value = draft.title || '';
|
|
1092
|
-
|
|
1441
|
+
descInput.value = draft.description || '';
|
|
1093
1442
|
priority = PRIORITIES.some((p) => p.value === draft.priority) ? draft.priority : 'MEDIUM';
|
|
1094
1443
|
scrim.querySelectorAll('[data-prio]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.prio === priority)));
|
|
1095
1444
|
for (const item of (draft.items || []).slice(0, cfg.maxAttachments ?? 5)) addItem(item);
|
|
@@ -1167,7 +1516,6 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1167
1516
|
document.addEventListener('keydown', onKeydown, true);
|
|
1168
1517
|
scrim.addEventListener('click', (e) => { if (e.target === scrim && !sending) close(); });
|
|
1169
1518
|
$('[data-act="close"]').addEventListener('click', close);
|
|
1170
|
-
$('[data-act="cancel"]').addEventListener('click', close);
|
|
1171
1519
|
|
|
1172
1520
|
/* ---------- severity ---------- */
|
|
1173
1521
|
|
|
@@ -1184,16 +1532,32 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1184
1532
|
/* ---------- attachments ---------- */
|
|
1185
1533
|
|
|
1186
1534
|
function say(text, kind = 'err') {
|
|
1187
|
-
slot.innerHTML = text
|
|
1535
|
+
slot.innerHTML = text
|
|
1536
|
+
? `<div class="msg ${kind}">${kind === 'err' ? ICONS.alert : ''}<span>${escapeHtml(text)}</span></div>`
|
|
1537
|
+
: '';
|
|
1538
|
+
}
|
|
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)));
|
|
1188
1548
|
}
|
|
1549
|
+
titleInput.addEventListener('input', () => { if (titleInput.value.trim()) flagTitle(''); });
|
|
1189
1550
|
|
|
1190
1551
|
function refreshSendState() {
|
|
1191
1552
|
const busy = items.some((it) => it.status === 'uploading');
|
|
1192
1553
|
sendBtn.disabled = sending || busy || items.some((it) => it.status === 'failed');
|
|
1193
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;
|
|
1194
1556
|
scrim.querySelectorAll('.tool').forEach((t) => {
|
|
1195
|
-
t.disabled = sending || items.length >=
|
|
1557
|
+
t.disabled = sending || items.length >= max;
|
|
1196
1558
|
});
|
|
1559
|
+
const count = $('.field-head .count');
|
|
1560
|
+
if (count) count.textContent = items.length ? `${items.length} of ${max}` : '';
|
|
1197
1561
|
}
|
|
1198
1562
|
|
|
1199
1563
|
function drawThumb(item) {
|
|
@@ -1405,7 +1769,7 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1405
1769
|
/** What the report already carries, read out of what they typed. */
|
|
1406
1770
|
function referencesInText() {
|
|
1407
1771
|
if (refConfig.policy === 'off' || !refConfig.fields.length) return [];
|
|
1408
|
-
return matchReferences(refConfig.fields, [titleInput.value,
|
|
1772
|
+
return matchReferences(refConfig.fields, [titleInput.value, descInput.value]);
|
|
1409
1773
|
}
|
|
1410
1774
|
|
|
1411
1775
|
/** The answer to send, or an empty list. */
|
|
@@ -1420,29 +1784,52 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1420
1784
|
const optional = refConfig.policy !== 'required';
|
|
1421
1785
|
|
|
1422
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;
|
|
1423
1790
|
refsBox.innerHTML = `
|
|
1424
|
-
<
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
: 'We need one
|
|
1428
|
-
|
|
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>` : ''}
|
|
1429
1808
|
<div class="row">
|
|
1430
|
-
${
|
|
1809
|
+
${many ? `
|
|
1431
1810
|
<select class="ref-kind" aria-label="Which detail you are giving">
|
|
1432
1811
|
${refConfig.fields.map((f) => `
|
|
1433
1812
|
<option value="${escapeHtml(f.key)}" ${f.key === field.key ? 'selected' : ''}>${escapeHtml(f.label)}</option>
|
|
1434
1813
|
`).join('')}
|
|
1435
1814
|
</select>` : ''}
|
|
1436
|
-
<input class="ref-value" type="${field.inputMode === 'email' ? 'email' : 'text'}"
|
|
1815
|
+
<input class="ref-value${refError ? ' invalid' : ''}" type="${field.inputMode === 'email' ? 'email' : 'text'}"
|
|
1437
1816
|
inputmode="${escapeHtml(field.inputMode === 'numeric' || field.inputMode === 'tel' ? 'numeric' : 'text')}"
|
|
1438
1817
|
value="${escapeHtml(refValue)}"
|
|
1439
|
-
placeholder="${escapeHtml(field.placeholder ||
|
|
1440
|
-
aria-label="${escapeHtml(field.label)}" />
|
|
1818
|
+
placeholder="${escapeHtml(field.placeholder || field.label)}"
|
|
1819
|
+
aria-label="${escapeHtml(field.label)}" ${refError ? 'aria-invalid="true"' : ''} />
|
|
1441
1820
|
</div>
|
|
1442
1821
|
${field.hint ? `<p class="hint">${escapeHtml(field.hint)}</p>` : ''}
|
|
1443
|
-
${refError ? `<p class="
|
|
1444
|
-
${optional ? '<button type="button" class="link" data-act="ref-skip">I don\'t have this</button>' : ''}`;
|
|
1445
|
-
|
|
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
|
+
});
|
|
1446
1833
|
const value = refsBox.querySelector('.ref-value');
|
|
1447
1834
|
value.addEventListener('input', () => {
|
|
1448
1835
|
refValue = value.value;
|
|
@@ -1497,10 +1884,8 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1497
1884
|
sendBtn.addEventListener('click', async () => {
|
|
1498
1885
|
const title = titleInput.value;
|
|
1499
1886
|
if (!title.trim()) {
|
|
1500
|
-
|
|
1501
|
-
titleInput.classList.add('invalid');
|
|
1887
|
+
flagTitle('Add a few words about what went wrong — a short sentence is plenty.');
|
|
1502
1888
|
titleInput.focus();
|
|
1503
|
-
setTimeout(() => titleInput.classList.remove('invalid'), 500);
|
|
1504
1889
|
return;
|
|
1505
1890
|
}
|
|
1506
1891
|
|
|
@@ -1538,12 +1923,12 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1538
1923
|
say('');
|
|
1539
1924
|
refreshSendState();
|
|
1540
1925
|
const original = sendBtn.innerHTML;
|
|
1541
|
-
sendBtn.innerHTML = '<span class="spinner"></span>Sending…';
|
|
1926
|
+
sendBtn.innerHTML = '<span class="spinner" aria-hidden="true"></span>Sending…';
|
|
1542
1927
|
|
|
1543
1928
|
try {
|
|
1544
1929
|
const res = await client.report({
|
|
1545
1930
|
title,
|
|
1546
|
-
description:
|
|
1931
|
+
description: descInput.value,
|
|
1547
1932
|
priority,
|
|
1548
1933
|
references: answeredReference(),
|
|
1549
1934
|
attachments: items
|
|
@@ -1790,7 +2175,7 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1790
2175
|
<input type="email" data-fld="email" value="${escapeHtml(pendingEmail)}"
|
|
1791
2176
|
placeholder="you@example.com" autocomplete="email" spellcheck="false"
|
|
1792
2177
|
aria-label="Your email address" ${signInBusy ? 'disabled' : ''} />
|
|
1793
|
-
<button type="button" class="
|
|
2178
|
+
<button type="button" class="primary" data-act="code" ${signInBusy ? 'disabled' : ''}>
|
|
1794
2179
|
${signInBusy ? 'Sending…' : 'Send code'}
|
|
1795
2180
|
</button>
|
|
1796
2181
|
</div>
|
|
@@ -1807,7 +2192,7 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1807
2192
|
<input type="text" class="code" data-fld="code" value="${escapeHtml(pendingCode)}"
|
|
1808
2193
|
placeholder="123456" inputmode="numeric" autocomplete="one-time-code"
|
|
1809
2194
|
maxlength="8" aria-label="The code we emailed you" ${signInBusy ? 'disabled' : ''} />
|
|
1810
|
-
<button type="button" class="
|
|
2195
|
+
<button type="button" class="primary" data-act="confirm" ${signInBusy ? 'disabled' : ''}>
|
|
1811
2196
|
${signInBusy ? 'Checking…' : 'Sign in'}
|
|
1812
2197
|
</button>
|
|
1813
2198
|
</div>
|
|
@@ -1941,7 +2326,7 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1941
2326
|
} catch (err) {
|
|
1942
2327
|
sending = false;
|
|
1943
2328
|
callBtn.disabled = false;
|
|
1944
|
-
callBtn.
|
|
2329
|
+
callBtn.innerHTML = `${ICONS.phone}Talk to the team`;
|
|
1945
2330
|
refreshSendState();
|
|
1946
2331
|
// A report filed with no call is still a report. Saying "the call
|
|
1947
2332
|
// failed" and nothing else would leave the reporter thinking their
|
|
@@ -2051,6 +2436,18 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
2051
2436
|
* sentence somebody started reading is the reason they open it again.
|
|
2052
2437
|
*/
|
|
2053
2438
|
function succeed(res, { callFailed = null } = {}) {
|
|
2439
|
+
// The assistant: straight into the report's conversation, where the
|
|
2440
|
+
// investigation shows itself working ("Analyzing the issue…") and asks
|
|
2441
|
+
// what it needs. The plain "Thanks" below stays for when that view cannot
|
|
2442
|
+
// load — a report filed without a way to follow it.
|
|
2443
|
+
if (res?.id && !callFailed) {
|
|
2444
|
+
showReport(res.id, { fresh: true }).then((ok) => { if (!ok) doneScreen(res, { callFailed }); });
|
|
2445
|
+
return;
|
|
2446
|
+
}
|
|
2447
|
+
doneScreen(res, { callFailed });
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
function doneScreen(res, { callFailed = null } = {}) {
|
|
2054
2451
|
const ref = res?.id ? String(res.id).slice(0, 8) : null;
|
|
2055
2452
|
const eta = res?.estimate?.label ? String(res.estimate.label) : null;
|
|
2056
2453
|
// A press of Call that filed the report and then could not ring anybody.
|
|
@@ -2079,16 +2476,12 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
2079
2476
|
*/
|
|
2080
2477
|
async function showHistory() {
|
|
2081
2478
|
const panel = scrim.querySelector('.panel');
|
|
2479
|
+
clearTimeout(assistantTimer);
|
|
2480
|
+
openReportId = null;
|
|
2481
|
+
panel.classList.remove('is-chat');
|
|
2082
2482
|
panel.innerHTML = `
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
<div class="titles">
|
|
2086
|
-
<h2>Your reports</h2>
|
|
2087
|
-
<p>Everything you've reported, and where each one got to.</p>
|
|
2088
|
-
</div>
|
|
2089
|
-
<button class="icon-btn" type="button" data-act="close" aria-label="Close">${ICONS.close}</button>
|
|
2090
|
-
</div>
|
|
2091
|
-
<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>`;
|
|
2092
2485
|
|
|
2093
2486
|
panel.querySelector('[data-act="close"]').addEventListener('click', close);
|
|
2094
2487
|
|
|
@@ -2096,8 +2489,8 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
2096
2489
|
try {
|
|
2097
2490
|
({ reports } = await client.myReports());
|
|
2098
2491
|
} catch {
|
|
2099
|
-
panel.querySelector('.history')
|
|
2100
|
-
|
|
2492
|
+
const failed = panel.querySelector('.history');
|
|
2493
|
+
if (failed) failed.innerHTML = emptyHtml('Couldn\'t load your reports just now.', ICONS.alert);
|
|
2101
2494
|
return;
|
|
2102
2495
|
}
|
|
2103
2496
|
|
|
@@ -2105,16 +2498,19 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
2105
2498
|
const list = panel.querySelector('.history');
|
|
2106
2499
|
if (!list) return;
|
|
2107
2500
|
|
|
2501
|
+
list.removeAttribute('aria-busy');
|
|
2108
2502
|
list.innerHTML = reports.length
|
|
2109
|
-
? reports.map((r) => `
|
|
2110
|
-
<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>
|
|
2111
2506
|
<span class="who">
|
|
2112
2507
|
<span class="ttl">${escapeHtml(r.title)}</span>
|
|
2113
2508
|
<span class="sub">${escapeHtml(r.reference)} · ${escapeHtml(relativeDay(r.createdAt))}</span>
|
|
2114
2509
|
</span>
|
|
2115
2510
|
<span class="pill ${escapeHtml(String(r.status).toLowerCase())}">${escapeHtml(statusWord(r.status))}</span>
|
|
2511
|
+
<span class="chev" aria-hidden="true">${ICONS.chevronRight}</span>
|
|
2116
2512
|
</button>`).join('')
|
|
2117
|
-
: '
|
|
2513
|
+
: emptyHtml('You haven\'t reported anything yet.', ICONS.inbox);
|
|
2118
2514
|
|
|
2119
2515
|
for (const row of list.querySelectorAll('[data-report]')) {
|
|
2120
2516
|
row.addEventListener('click', () => showReport(row.dataset.report));
|
|
@@ -2134,18 +2530,17 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
2134
2530
|
* bubbles either side of them; and there is a box at the bottom. Oldest at
|
|
2135
2531
|
* the top, because that is the direction a conversation is read.
|
|
2136
2532
|
*/
|
|
2137
|
-
async function showReport(bugId) {
|
|
2533
|
+
async function showReport(bugId, { fresh = false } = {}) {
|
|
2138
2534
|
const panel = scrim.querySelector('.panel');
|
|
2535
|
+
clearTimeout(assistantTimer);
|
|
2536
|
+
panel.classList.add('is-chat');
|
|
2139
2537
|
panel.innerHTML = `
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
<button class="icon-btn" type="button" data-act="close" aria-label="Close">${ICONS.close}</button>
|
|
2147
|
-
</div>
|
|
2148
|
-
<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>`;
|
|
2149
2544
|
|
|
2150
2545
|
panel.querySelector('[data-act="close"]').addEventListener('click', close);
|
|
2151
2546
|
panel.querySelector('[data-act="back"]').addEventListener('click', showHistory);
|
|
@@ -2159,11 +2554,13 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
2159
2554
|
|
|
2160
2555
|
const body = panel.querySelector('.history');
|
|
2161
2556
|
// The panel can be closed, or navigated away from, while this is in flight.
|
|
2162
|
-
if (!body || !panel.querySelector('[data-act="back"]')) return;
|
|
2557
|
+
if (!body || !panel.querySelector('[data-act="back"]')) return false;
|
|
2163
2558
|
|
|
2164
2559
|
if (!report) {
|
|
2165
|
-
|
|
2166
|
-
|
|
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);
|
|
2563
|
+
return false;
|
|
2167
2564
|
}
|
|
2168
2565
|
|
|
2169
2566
|
openReportId = bugId;
|
|
@@ -2171,7 +2568,9 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
2171
2568
|
// fixed" card up over the screen where they are reading that it was fixed
|
|
2172
2569
|
// would be the notification arguing with itself.
|
|
2173
2570
|
client.markReportRead(bugId);
|
|
2571
|
+
const drawn = threadHtml(report.timeline);
|
|
2174
2572
|
body.classList.add('chatting');
|
|
2573
|
+
body.removeAttribute('aria-busy');
|
|
2175
2574
|
body.innerHTML = `
|
|
2176
2575
|
<div class="report-head">
|
|
2177
2576
|
<span class="ttl">${escapeHtml(report.title)}</span>
|
|
@@ -2186,12 +2585,101 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
2186
2585
|
? `<span class="eta-line">Usually takes ${escapeHtml(String(report.estimate.label))}</span>`
|
|
2187
2586
|
: ''}
|
|
2188
2587
|
</div>
|
|
2189
|
-
<div class="thread">${
|
|
2588
|
+
<div class="thread" role="log" aria-live="polite" aria-label="Conversation">${drawn}</div>`;
|
|
2190
2589
|
|
|
2191
2590
|
const thread = body.querySelector('.thread');
|
|
2591
|
+
threadDrawn.set(thread, drawn);
|
|
2192
2592
|
scrollToLatest(thread);
|
|
2193
2593
|
mountVerification(body, report);
|
|
2194
2594
|
if (report.canReply) mountComposer(body, thread, bugId);
|
|
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);
|
|
2599
|
+
return true;
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
/**
|
|
2603
|
+
* The assistant's live card: where the investigation is, and a gentle
|
|
2604
|
+
* refresh while it works. Analyzing polls every few seconds until the
|
|
2605
|
+
* outcome lands — a question, a found cause, or a hand-off — so the
|
|
2606
|
+
* reporter watches it happen rather than wondering whether anything is.
|
|
2607
|
+
*/
|
|
2608
|
+
let assistantTimer = null;
|
|
2609
|
+
function mountAssistant(body, report, bugId) {
|
|
2610
|
+
clearTimeout(assistantTimer);
|
|
2611
|
+
const a = report.assistant;
|
|
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
|
+
}
|
|
2631
|
+
card.className = `assistant ${escapeHtml(a.phase)}`;
|
|
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>
|
|
2647
|
+
</div>
|
|
2648
|
+
</div>
|
|
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
|
+
|
|
2678
|
+
if (a.phase === 'analyzing') {
|
|
2679
|
+
assistantTimer = setTimeout(() => refreshThread(bugId), 3000);
|
|
2680
|
+
} else if (a.phase === 'needs_info' && previous !== 'needs_info') {
|
|
2681
|
+
body.querySelector('.composer textarea')?.focus();
|
|
2682
|
+
}
|
|
2195
2683
|
}
|
|
2196
2684
|
|
|
2197
2685
|
/**
|
|
@@ -2230,7 +2718,14 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
2230
2718
|
|
|
2231
2719
|
mountVerification(body, report);
|
|
2232
2720
|
const pinned = atLatest(thread);
|
|
2233
|
-
|
|
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);
|
|
2234
2729
|
// Only follow the conversation down if they were already at the bottom.
|
|
2235
2730
|
// Yanking someone back from something they scrolled up to read is worse
|
|
2236
2731
|
// than making them scroll.
|
|
@@ -2433,7 +2928,7 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
2433
2928
|
at: new Date().toISOString(),
|
|
2434
2929
|
attachments: ready.map((f) => ({ type: 'FILE', name: f.name, size: f.size })),
|
|
2435
2930
|
}, 'pending'));
|
|
2436
|
-
scrollToLatest(thread);
|
|
2931
|
+
scrollToLatest(thread, { smooth: true });
|
|
2437
2932
|
|
|
2438
2933
|
try {
|
|
2439
2934
|
await sendMessage({
|
|
@@ -2583,14 +3078,38 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
2583
3078
|
function threadHtml(timeline) {
|
|
2584
3079
|
const entries = timeline || [];
|
|
2585
3080
|
if (!entries.length) return '';
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
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 || ''}`;
|
|
2594
3113
|
}
|
|
2595
3114
|
|
|
2596
3115
|
/**
|
|
@@ -2601,15 +3120,17 @@ function threadHtml(timeline) {
|
|
|
2601
3120
|
* portal exactly rather than being a third attempt at the same sentence.
|
|
2602
3121
|
*/
|
|
2603
3122
|
function systemHtml(entry) {
|
|
3123
|
+
const when = clockTime(entry.at);
|
|
2604
3124
|
return `
|
|
2605
3125
|
<div class="sys k-${escapeHtml(entry.kind)}">
|
|
2606
|
-
<span class="dot" aria-hidden="true"></span>${escapeHtml(entry.summary || '')}
|
|
2607
|
-
|
|
3126
|
+
<span class="dot" aria-hidden="true"></span>${escapeHtml(entry.summary || '')}${when ? `
|
|
3127
|
+
<span class="t">· ${escapeHtml(when)}</span>` : ''}
|
|
2608
3128
|
</div>`;
|
|
2609
3129
|
}
|
|
2610
3130
|
|
|
2611
3131
|
/**
|
|
2612
|
-
* 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.
|
|
2613
3134
|
*
|
|
2614
3135
|
* A reply that was sent automatically says so, above the words rather than
|
|
2615
3136
|
* after them. The person reading it is deciding whether their problem has been
|
|
@@ -2633,30 +3154,132 @@ function messageHtml(message, state = '') {
|
|
|
2633
3154
|
|
|
2634
3155
|
// Named, or labelled, never both and never neither: an automated message has
|
|
2635
3156
|
// no author to name, and naming one would be the lie the label exists to
|
|
2636
|
-
// prevent.
|
|
3157
|
+
// prevent. The avatar says the same thing at a glance.
|
|
2637
3158
|
let who = '';
|
|
2638
|
-
|
|
2639
|
-
|
|
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 = '';
|
|
2640
3169
|
|
|
2641
3170
|
// "Is this the problem you are facing?" — the confirm question the
|
|
2642
3171
|
// investigation asks (bugcatch-workers check-loop). One tap answers it; the
|
|
2643
3172
|
// buttons only show on the latest message, so an answered question has none.
|
|
2644
3173
|
const confirm = automated && /reply "yes" or "no"/i.test(body)
|
|
2645
3174
|
? `<div class="quick" role="group" aria-label="Answer">
|
|
2646
|
-
<button type="button" data-quick="Yes, this is the problem"
|
|
3175
|
+
<button type="button" data-quick="Yes, this is the problem">${ICONS.check}Yes, that's it</button>
|
|
2647
3176
|
<button type="button" data-quick="No, that is not the problem">No, it's something else</button>
|
|
2648
3177
|
</div>`
|
|
2649
3178
|
: '';
|
|
2650
3179
|
|
|
2651
3180
|
return `
|
|
2652
3181
|
<div class="msg-row ${mine ? 'mine' : 'theirs'} ${automated ? 'auto' : ''} ${state}">
|
|
3182
|
+
${avatar}
|
|
2653
3183
|
${who}
|
|
2654
3184
|
<div class="bubble ${body ? '' : 'silent'}">${text}${files}</div>
|
|
2655
3185
|
${confirm}
|
|
2656
|
-
<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>
|
|
2657
3197
|
</div>`;
|
|
2658
3198
|
}
|
|
2659
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>
|
|
3214
|
+
</div>`;
|
|
3215
|
+
}
|
|
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
|
+
|
|
2660
3283
|
/**
|
|
2661
3284
|
* One attachment, rendered as what it is.
|
|
2662
3285
|
*
|
|
@@ -2691,8 +3314,10 @@ function atLatest(thread) {
|
|
|
2691
3314
|
return thread.scrollHeight - thread.scrollTop - thread.clientHeight < 40;
|
|
2692
3315
|
}
|
|
2693
3316
|
|
|
2694
|
-
function scrollToLatest(thread) {
|
|
2695
|
-
|
|
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;
|
|
2696
3321
|
}
|
|
2697
3322
|
|
|
2698
3323
|
/** Status as a word a reporter would use, not the enum the database stores. */
|