@sphoro/bugcatch-web-sdk 0.14.4 → 0.16.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 +2798 -1797
- package/dist/bugcatch.es.js.map +1 -1
- package/dist/bugcatch.umd.js +804 -450
- 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 +326 -36
- package/src/references.js +155 -3
- package/src/updates.js +36 -6
- package/src/widget.js +1170 -456
package/src/widget.js
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
import { draftKey, draftOperation, readDraft } from './drafts.js';
|
|
15
15
|
import { readSession } from './session.js';
|
|
16
16
|
import { sendMessage } from './updates.js';
|
|
17
|
-
import {
|
|
17
|
+
import { readReferences, validateReference } from './references.js';
|
|
18
18
|
|
|
19
19
|
const POSITIONS = {
|
|
20
20
|
'bottom-right': 'bottom:20px;right:20px;',
|
|
@@ -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,117 @@ 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
|
-
|
|
389
|
-
|
|
390
|
-
.
|
|
391
|
-
|
|
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); }
|
|
506
|
+
.refs .chips.cands button { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 550; }
|
|
507
|
+
/* "We read your ID" — under the description, while they type. */
|
|
508
|
+
.ref-found {
|
|
509
|
+
display: flex; align-items: center; gap: 6px;
|
|
510
|
+
margin: -6px 0 14px; font-size: 12px; line-height: 1.45; color: var(--bc-muted);
|
|
511
|
+
}
|
|
512
|
+
.ref-found svg { width: 14px; height: 14px; flex: none; color: var(--bc-ok); }
|
|
513
|
+
.ref-found b { font-weight: 600; color: var(--bc-fg); }
|
|
392
514
|
|
|
393
515
|
/* ---------- severity ---------- */
|
|
394
|
-
.seg { display: grid; grid-template-columns: repeat(4, 1fr); gap: 3px; padding: 3px; background: var(--bc-subtle); border-radius:
|
|
516
|
+
.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
517
|
.seg button {
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
518
|
+
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
|
519
|
+
padding: 8px 4px; font-size: var(--bc-fs-sm); font-weight: 550; line-height: 1;
|
|
520
|
+
color: var(--bc-muted); background: none; border: none; border-radius: 9px;
|
|
521
|
+
cursor: pointer; transition: background 180ms var(--bc-ease), color 180ms, box-shadow 180ms;
|
|
522
|
+
}
|
|
523
|
+
.seg button::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: var(--sev, var(--bc-faint)); opacity: .55; transition: opacity 180ms; }
|
|
524
|
+
.seg button[data-prio="LOW"] { --sev: #94a3b8; }
|
|
525
|
+
.seg button[data-prio="MEDIUM"] { --sev: #3b82f6; }
|
|
526
|
+
.seg button[data-prio="HIGH"] { --sev: #f59e0b; }
|
|
527
|
+
.seg button[data-prio="CRITICAL"] { --sev: #ef4444; }
|
|
400
528
|
.seg button:hover { color: var(--bc-fg); }
|
|
401
529
|
.seg button[aria-pressed=true] {
|
|
402
|
-
background: var(--bc-raised); color: var(--bc-
|
|
403
|
-
box-shadow: 0 1px 3px rgba(10,12,16,.14);
|
|
530
|
+
background: var(--bc-raised); color: var(--bc-fg); font-weight: 650;
|
|
531
|
+
box-shadow: 0 1px 3px rgba(10,12,16,.14), 0 0 0 1px color-mix(in srgb, var(--bc-border) 60%, transparent);
|
|
404
532
|
}
|
|
405
|
-
.seg button
|
|
533
|
+
.seg button[aria-pressed=true]::before { opacity: 1; }
|
|
534
|
+
.seg button:focus-visible { outline-offset: -2px; }
|
|
406
535
|
|
|
407
536
|
/* ---------- attachments ---------- */
|
|
408
|
-
.tools { display:
|
|
537
|
+
.tools { display: grid; grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); gap: 8px; }
|
|
409
538
|
.tool {
|
|
410
|
-
display: inline-flex; align-items: center; gap: 6px;
|
|
411
|
-
padding: 8px
|
|
539
|
+
display: inline-flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px;
|
|
540
|
+
padding: 12px 8px; font-size: 12px; font-weight: 600; line-height: 1;
|
|
412
541
|
color: var(--bc-fg); background: var(--bc-elevated);
|
|
413
|
-
border: 1px
|
|
414
|
-
transition: border-color 160ms, background 160ms, transform 160ms var(--bc-ease);
|
|
542
|
+
border: 1px dashed color-mix(in srgb, var(--bc-fg) 20%, var(--bc-border)); border-radius: var(--bc-r-md); cursor: pointer;
|
|
543
|
+
transition: border-color 160ms, background 160ms, color 160ms, transform 160ms var(--bc-ease);
|
|
415
544
|
}
|
|
416
|
-
.tool svg { font-size:
|
|
417
|
-
.tool:hover:not(:disabled) { border-color: var(--bc-accent); transform: translateY(-1px); }
|
|
545
|
+
.tool svg { font-size: 19px; color: var(--bc-muted); transition: color 160ms; }
|
|
546
|
+
.tool:hover:not(:disabled) { border-color: var(--bc-accent); border-style: solid; background: var(--bc-accent-soft); transform: translateY(-1px); }
|
|
418
547
|
.tool:hover:not(:disabled) svg { color: var(--bc-accent); }
|
|
419
548
|
.tool:active:not(:disabled) { transform: translateY(0) scale(.98); }
|
|
420
|
-
.tool:focus-visible { outline: 2px solid var(--bc-accent); outline-offset: 1px; }
|
|
421
549
|
.tool:disabled { opacity: .45; cursor: not-allowed; }
|
|
550
|
+
.attach-hint { margin: 8px 0 0; font-size: var(--bc-fs-xs); color: var(--bc-faint); }
|
|
422
551
|
|
|
423
552
|
.thumbs { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
|
|
553
|
+
.thumbs:empty { display: none; }
|
|
424
554
|
.thumb {
|
|
425
|
-
position: relative; width:
|
|
426
|
-
border-radius:
|
|
555
|
+
position: relative; width: 76px; height: 76px; flex: none;
|
|
556
|
+
border-radius: var(--bc-r-md); overflow: hidden;
|
|
427
557
|
border: 1px solid var(--bc-border); background: var(--bc-subtle);
|
|
558
|
+
box-shadow: var(--bc-shadow-sm);
|
|
428
559
|
animation: bc-item-in 300ms var(--bc-ease) both;
|
|
429
560
|
}
|
|
430
561
|
.thumb img, .thumb video { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
@@ -435,11 +566,11 @@ textarea { min-height: 84px; resize: vertical; }
|
|
|
435
566
|
background: rgba(10,12,16,.72); color: #fff; font-size: 9.5px; font-weight: 600; line-height: 1.3;
|
|
436
567
|
}
|
|
437
568
|
.thumb .drop {
|
|
438
|
-
position: absolute; top:
|
|
439
|
-
width:
|
|
440
|
-
background: rgba(10,12,16,.
|
|
569
|
+
position: absolute; top: 4px; right: 4px;
|
|
570
|
+
width: 20px; height: 20px; font-size: 11px; border-radius: 50%;
|
|
571
|
+
background: rgba(10,12,16,.7); color: #fff; border: none;
|
|
441
572
|
display: grid; place-items: center; cursor: pointer;
|
|
442
|
-
opacity: 0; transition: opacity 160ms;
|
|
573
|
+
opacity: 0; transition: opacity 160ms, background 160ms;
|
|
443
574
|
}
|
|
444
575
|
.thumb:hover .drop, .thumb:focus-within .drop { opacity: 1; }
|
|
445
576
|
.thumb .drop:hover { background: var(--bc-danger); }
|
|
@@ -463,354 +594,538 @@ textarea { min-height: 84px; resize: vertical; }
|
|
|
463
594
|
|
|
464
595
|
/* ---------- messages ---------- */
|
|
465
596
|
.msg {
|
|
466
|
-
display: flex; align-items: flex-start; gap:
|
|
467
|
-
font-size:
|
|
597
|
+
display: flex; align-items: flex-start; gap: 8px;
|
|
598
|
+
font-size: var(--bc-fs-sm); line-height: 1.45; padding: 10px 12px; border-radius: var(--bc-r-md); margin-bottom: 14px;
|
|
468
599
|
animation: bc-item-in 240ms var(--bc-ease) both;
|
|
469
600
|
}
|
|
470
|
-
.msg
|
|
471
|
-
.msg.
|
|
601
|
+
.msg svg { flex: none; font-size: 15px; margin-top: 1px; }
|
|
602
|
+
.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); }
|
|
603
|
+
.msg.info { background: var(--bc-subtle); color: var(--bc-muted); border: 1px solid var(--bc-border); }
|
|
472
604
|
|
|
473
605
|
/* ---------- footer ---------- */
|
|
474
606
|
.foot {
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
607
|
+
flex: none;
|
|
608
|
+
display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
|
|
609
|
+
padding: 12px var(--bc-pad); border-top: 1px solid var(--bc-border);
|
|
610
|
+
background: var(--bc-bg);
|
|
478
611
|
}
|
|
479
612
|
.foot .spacer { flex: 1; }
|
|
480
|
-
.foot .note { font-size:
|
|
613
|
+
.foot .note { flex-basis: 100%; order: -1; font-size: var(--bc-fs-xs); color: var(--bc-muted); }
|
|
614
|
+
.foot .note:empty { display: none; }
|
|
481
615
|
/* Quiet, but still legible: this sits in someone else's product, so it identifies
|
|
482
616
|
where the report goes without competing with their interface. Quiet is bought
|
|
483
617
|
with colour (--bc-brand-ink sits a step below body text) rather than with a
|
|
484
618
|
blanket opacity — the BUGCATCH half is only about 3px of cap height at this
|
|
485
619
|
size, and dimming it on top of that left it as a smudge in both themes. */
|
|
486
|
-
.
|
|
487
|
-
|
|
620
|
+
.powered {
|
|
621
|
+
flex: none; display: flex; align-items: center; justify-content: center; gap: 6px;
|
|
622
|
+
padding: 7px var(--bc-pad) 9px; font-size: 10.5px; color: var(--bc-faint);
|
|
623
|
+
background: var(--bc-subtle); border-top: 1px solid var(--bc-border);
|
|
624
|
+
}
|
|
625
|
+
.brand-credit { display: flex; align-items: center; transition: color 0.15s; border-radius: 4px; }
|
|
626
|
+
.bc-brand { height: 16px; width: auto; display: block; color: var(--bc-brand-ink); }
|
|
488
627
|
.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
628
|
button.ghost, button.primary {
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
button.ghost
|
|
629
|
+
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
|
|
630
|
+
height: 38px; padding: 0 15px; font: inherit; font-size: 13px; font-weight: 620; line-height: 1;
|
|
631
|
+
border-radius: 999px; cursor: pointer; white-space: nowrap;
|
|
632
|
+
transition: transform 160ms var(--bc-ease), filter 160ms, background 160ms, border-color 160ms, color 160ms, box-shadow 160ms;
|
|
633
|
+
}
|
|
634
|
+
button.ghost { background: var(--bc-bg); border: 1px solid var(--bc-border); color: var(--bc-fg); }
|
|
635
|
+
button.ghost:hover:not(:disabled) { background: var(--bc-subtle); border-color: color-mix(in srgb, var(--bc-fg) 20%, var(--bc-border)); }
|
|
636
|
+
button.ghost svg, button.primary svg { font-size: 15px; }
|
|
497
637
|
button.primary {
|
|
498
|
-
|
|
638
|
+
padding: 0 18px;
|
|
499
639
|
background: var(--bc-accent); border: 1px solid var(--bc-accent); color: var(--bc-on-accent);
|
|
640
|
+
box-shadow: 0 4px 12px color-mix(in srgb, var(--bc-accent) 30%, transparent);
|
|
500
641
|
}
|
|
501
642
|
button.primary:hover:not(:disabled) { transform: translateY(-1px); filter: brightness(1.07); }
|
|
502
643
|
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
644
|
button:disabled { opacity: .5; cursor: not-allowed; }
|
|
505
|
-
button.primary
|
|
645
|
+
button.primary:disabled { box-shadow: none; }
|
|
646
|
+
button.primary .spinner { border-color: rgba(255,255,255,.4); border-top-color: #fff; width: 14px; height: 14px; }
|
|
647
|
+
button.quiet {
|
|
648
|
+
padding: 0 4px; height: 38px; font: inherit; font-size: 13px; font-weight: 600;
|
|
649
|
+
border: none; background: none; color: var(--bc-muted); cursor: pointer; border-radius: 8px;
|
|
650
|
+
}
|
|
651
|
+
button.quiet:hover { color: var(--bc-accent); }
|
|
506
652
|
|
|
507
|
-
/* ---------- success ---------- */
|
|
508
653
|
/* ---------- your reports ---------- */
|
|
509
|
-
.history {
|
|
654
|
+
.history {
|
|
655
|
+
flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
|
|
656
|
+
padding: 8px 12px 14px;
|
|
657
|
+
}
|
|
510
658
|
/* On the conversation screen the inner thread does the scrolling, so this
|
|
511
659
|
wrapper must not — two nested scrollers means the composer drifts out of
|
|
512
660
|
reach exactly when someone reaches for it. */
|
|
513
661
|
.history.chatting {
|
|
514
|
-
|
|
662
|
+
overflow: hidden; display: flex; flex-direction: column; padding: 0;
|
|
515
663
|
}
|
|
516
664
|
.history .row {
|
|
517
|
-
display: flex; align-items:
|
|
518
|
-
padding:
|
|
665
|
+
display: flex; align-items: center; gap: 12px;
|
|
666
|
+
padding: 12px 10px; border-bottom: 1px solid var(--bc-border);
|
|
519
667
|
}
|
|
520
668
|
.history .row:last-child { border-bottom: 0; }
|
|
521
669
|
.history .who { flex: 1; min-width: 0; }
|
|
522
670
|
.history .ttl {
|
|
523
|
-
display: block; font-size:
|
|
671
|
+
display: block; font-size: 13.5px; font-weight: 600; color: var(--bc-fg);
|
|
524
672
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
525
673
|
}
|
|
526
|
-
.history .sub { display: block; margin-top:
|
|
527
|
-
.history .
|
|
674
|
+
.history .sub { display: block; margin-top: 3px; font-size: var(--bc-fs-xs); color: var(--bc-faint); }
|
|
675
|
+
.history .row-ico {
|
|
676
|
+
flex: none; display: grid; place-items: center; width: 34px; height: 34px; border-radius: 10px;
|
|
677
|
+
background: var(--bc-subtle); color: var(--bc-muted); font-size: 16px;
|
|
678
|
+
}
|
|
679
|
+
.history .chev { flex: none; color: var(--bc-faint); font-size: 15px; transition: transform 160ms var(--bc-ease), color 160ms; }
|
|
680
|
+
.empty {
|
|
681
|
+
display: flex; flex-direction: column; align-items: center; gap: 10px;
|
|
682
|
+
padding: 40px 16px; text-align: center; font-size: var(--bc-fs-sm); color: var(--bc-muted);
|
|
683
|
+
}
|
|
684
|
+
.empty .empty-ico {
|
|
685
|
+
display: grid; place-items: center; width: 44px; height: 44px; border-radius: 50%;
|
|
686
|
+
background: var(--bc-accent-soft); color: var(--bc-accent); font-size: 20px;
|
|
687
|
+
}
|
|
688
|
+
.skel {
|
|
689
|
+
display: block; height: 12px; border-radius: 6px; margin: 6px 0;
|
|
690
|
+
background: linear-gradient(90deg, var(--bc-subtle) 25%, color-mix(in srgb, var(--bc-fg) 8%, var(--bc-subtle)) 50%, var(--bc-subtle) 75%);
|
|
691
|
+
background-size: 200% 100%; animation: bc-shimmer 1.4s linear infinite;
|
|
692
|
+
}
|
|
528
693
|
/* A history row is a button now — it opens the report — so the UA styling has
|
|
529
694
|
to go, and the hit area has to read as one. */
|
|
530
695
|
.history button.row {
|
|
531
|
-
width: 100%; background: none; border: 0;
|
|
696
|
+
width: 100%; background: none; border: 0; margin-bottom: 2px;
|
|
532
697
|
font: inherit; color: inherit; text-align: left; cursor: pointer;
|
|
533
|
-
border-radius:
|
|
698
|
+
border-radius: var(--bc-r-md); transition: background 160ms;
|
|
699
|
+
animation: bc-rise 260ms var(--bc-ease) both;
|
|
534
700
|
}
|
|
535
701
|
.history button.row:hover { background: var(--bc-subtle); }
|
|
536
|
-
.history button.row:
|
|
702
|
+
.history button.row:hover .chev { transform: translateX(2px); color: var(--bc-accent); }
|
|
703
|
+
.history button.row:focus-visible { outline-offset: -2px; }
|
|
537
704
|
|
|
538
705
|
/* ---------- one report, and what happened to it ---------- */
|
|
539
706
|
.report-head {
|
|
540
|
-
|
|
541
|
-
|
|
707
|
+
flex: none;
|
|
708
|
+
display: grid; grid-template-columns: 1fr auto; gap: 2px 10px; align-items: center;
|
|
709
|
+
padding: 10px var(--bc-pad); border-bottom: 1px solid var(--bc-border);
|
|
710
|
+
background: var(--bc-bg);
|
|
542
711
|
}
|
|
543
|
-
.report-head .ttl { font-size:
|
|
544
|
-
.report-head .sub { font-size:
|
|
545
|
-
.report-head .pill { grid-row: 1 / span 2; align-self: center; }
|
|
712
|
+
.report-head .ttl { font-size: 13px; font-weight: 620; color: var(--bc-fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
|
713
|
+
.report-head .sub { font-size: var(--bc-fs-xs); color: var(--bc-faint); }
|
|
714
|
+
.report-head .pill { grid-row: 1 / span 2; grid-column: 2; align-self: center; }
|
|
546
715
|
|
|
547
716
|
/* The thread scrolls; the composer under it does not. Both live inside the
|
|
548
717
|
panel, which stops scrolling as a whole on this screen — a composer that
|
|
549
718
|
scrolls away is a composer people think isn't there. */
|
|
550
719
|
.thread {
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
720
|
+
flex: 1 1 auto; min-height: 140px;
|
|
721
|
+
display: flex; flex-direction: column; gap: 4px;
|
|
722
|
+
padding: 14px var(--bc-pad) 12px; overflow-y: auto; overscroll-behavior: contain;
|
|
554
723
|
}
|
|
555
724
|
|
|
725
|
+
/* The day a run of messages happened on, as a centred label on a rule. */
|
|
726
|
+
.day-sep {
|
|
727
|
+
display: flex; align-items: center; gap: 10px; margin: 10px 0 6px;
|
|
728
|
+
font-size: 11px; font-weight: 620; letter-spacing: .02em; color: var(--bc-faint);
|
|
729
|
+
}
|
|
730
|
+
.day-sep::before, .day-sep::after { content: ''; flex: 1; height: 1px; background: var(--bc-border); }
|
|
731
|
+
|
|
556
732
|
/* A status change is not a message and is not drawn as one: no side, no
|
|
557
733
|
bubble, no author. Centred and quiet, the way every chat marks the things
|
|
558
734
|
that happened rather than the things that were said. */
|
|
559
735
|
.sys {
|
|
560
|
-
align-self: center; max-width: 90%; text-align: center;
|
|
561
|
-
|
|
736
|
+
align-self: center; max-width: 90%; text-align: center; margin: 6px 0;
|
|
737
|
+
padding: 4px 10px; border-radius: 999px; background: var(--bc-subtle);
|
|
738
|
+
font-size: var(--bc-fs-xs); line-height: 1.45; color: var(--bc-muted);
|
|
739
|
+
animation: bc-fade-in 240ms ease both;
|
|
562
740
|
}
|
|
563
741
|
.sys .dot {
|
|
564
742
|
display: inline-block; width: 6px; height: 6px; margin-right: 6px;
|
|
565
743
|
border-radius: 50%; background: var(--bc-muted); vertical-align: middle;
|
|
566
744
|
}
|
|
745
|
+
.sys .t { color: var(--bc-faint); }
|
|
567
746
|
.sys.k-status .dot { background: #4da3ff; }
|
|
568
747
|
.sys.k-severity .dot { background: #e8912a; }
|
|
569
748
|
.sys.k-assignment .dot { background: #8b7dff; }
|
|
570
749
|
.sys.k-filed .dot { background: var(--bc-faint); }
|
|
571
|
-
.icon-btn.back { margin-right: 2px; }
|
|
572
750
|
|
|
573
751
|
/* ---------- the conversation ----------
|
|
574
752
|
|
|
575
753
|
A status change and a message are different things and are drawn as such: a
|
|
576
754
|
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
|
-
|
|
755
|
+
reporter's own words sit right and accented, the other side's left beside an
|
|
756
|
+
avatar that says who it is — the arrangement every chat has taught people to
|
|
757
|
+
read without a legend. */
|
|
758
|
+
.msg-row, .typing-row {
|
|
759
|
+
display: grid; grid-template-columns: 28px minmax(0, 1fr); column-gap: 8px;
|
|
760
|
+
max-width: 88%; margin-top: 10px;
|
|
761
|
+
animation: bc-rise 260ms var(--bc-ease) both;
|
|
762
|
+
}
|
|
763
|
+
.msg-row.cont { margin-top: 3px; }
|
|
764
|
+
.msg-row > *, .typing-row > * { grid-column: 2; }
|
|
765
|
+
/* Fixed rows, so the avatar lines up with the bubble whatever else the
|
|
766
|
+
message carries (a name above it, answers or a time below it). */
|
|
767
|
+
.msg-row > .msg-who, .typing-row > .msg-who { grid-row: 1; }
|
|
768
|
+
.msg-row > .bubble, .typing-row > .bubble { grid-row: 2; }
|
|
769
|
+
.msg-row > .quick { grid-row: 3; }
|
|
770
|
+
.msg-row > .msg-when { grid-row: 4; }
|
|
771
|
+
.msg-row > .av, .typing-row > .av { grid-column: 1; grid-row: 2; align-self: end; }
|
|
772
|
+
/* In a run of messages the avatar sits by the last bubble and the time under
|
|
773
|
+
it, the way a chat marks where one turn ends. */
|
|
774
|
+
.msg-row:has(+ .msg-row.cont) > .av { visibility: hidden; }
|
|
775
|
+
.msg-row:has(+ .msg-row.cont) > .msg-when { display: none; }
|
|
776
|
+
.msg-row.theirs, .typing-row { align-self: flex-start; }
|
|
777
|
+
.msg-row.mine { align-self: flex-end; grid-template-columns: minmax(0, 1fr); justify-items: end; }
|
|
778
|
+
.msg-row.mine > * { grid-column: 1; }
|
|
779
|
+
.av {
|
|
780
|
+
display: grid; place-items: center; width: 28px; height: 28px; border-radius: 50%;
|
|
781
|
+
font-size: 11px; font-weight: 700; letter-spacing: .02em;
|
|
782
|
+
background: color-mix(in srgb, var(--bc-fg) 10%, var(--bc-bg)); color: var(--bc-fg);
|
|
783
|
+
}
|
|
784
|
+
.av.bot {
|
|
785
|
+
background: linear-gradient(140deg, var(--bc-accent), color-mix(in srgb, var(--bc-accent) 60%, #fff));
|
|
786
|
+
color: var(--bc-on-accent); font-size: 14px;
|
|
787
|
+
}
|
|
788
|
+
.msg-who { font-size: var(--bc-fs-xs); font-weight: 620; color: var(--bc-muted); padding: 0 4px 4px; }
|
|
789
|
+
.msg-who.auto { color: var(--bc-accent); display: inline-flex; align-items: center; gap: 5px; }
|
|
790
|
+
.msg-who .tag {
|
|
791
|
+
padding: 1px 6px; border-radius: 999px; font-size: 9.5px; font-weight: 700; letter-spacing: .04em;
|
|
792
|
+
text-transform: uppercase; background: var(--bc-accent-soft); color: var(--bc-accent);
|
|
793
|
+
}
|
|
584
794
|
.bubble {
|
|
585
|
-
|
|
795
|
+
justify-self: start; max-width: 100%;
|
|
796
|
+
padding: 9px 13px; border-radius: 18px; font-size: 13.5px; line-height: 1.5;
|
|
586
797
|
background: var(--bc-subtle); color: var(--bc-fg);
|
|
587
798
|
border: 1px solid var(--bc-border);
|
|
799
|
+
border-bottom-left-radius: 6px;
|
|
588
800
|
}
|
|
589
801
|
/* pre-wrap belongs to the text, not the box — on the box it would also render
|
|
590
802
|
the markup's own indentation. */
|
|
591
803
|
.msg-text { display: block; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
592
804
|
.msg-row.mine .bubble {
|
|
805
|
+
justify-self: end;
|
|
593
806
|
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); }
|
|
807
|
+
border-bottom-left-radius: 18px; border-bottom-right-radius: 6px;
|
|
808
|
+
}
|
|
809
|
+
.msg-row.cont.theirs .bubble { border-top-left-radius: 6px; }
|
|
810
|
+
.msg-row.cont.mine .bubble { border-top-right-radius: 6px; }
|
|
811
|
+
/* A reply nobody wrote: tinted with the accent and labelled "Assistant" above
|
|
812
|
+
it, so it reads the same to someone who cannot see the colour at all. */
|
|
813
|
+
.msg-row.auto .bubble { background: var(--bc-accent-soft); border-color: var(--bc-accent-line); }
|
|
602
814
|
/* An attachment-only message has no text to wrap, so the bubble would be an
|
|
603
815
|
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: .
|
|
816
|
+
.bubble.silent { padding: 0; background: none !important; border: 0; }
|
|
817
|
+
.msg-when { font-size: 10.5px; color: var(--bc-faint); padding: 4px 4px 0; }
|
|
818
|
+
.msg-row.pending { opacity: .6; }
|
|
819
|
+
.msg-row.pending .msg-when::after { content: ' · Sending…'; }
|
|
607
820
|
.msg-row.failed .bubble { border: 1px solid var(--bc-danger); }
|
|
821
|
+
.msg-row.failed .msg-when { color: var(--bc-danger); }
|
|
822
|
+
.msg-row.failed .msg-when::after { content: ' · Not sent'; }
|
|
823
|
+
|
|
824
|
+
/* The assistant, thinking. Three dots in a bubble where its next message will be. */
|
|
825
|
+
.typing { display: inline-flex; align-items: center; gap: 4px; padding: 12px 14px; justify-self: start; }
|
|
826
|
+
.typing i {
|
|
827
|
+
width: 7px; height: 7px; border-radius: 50%; background: var(--bc-accent); opacity: .45;
|
|
828
|
+
animation: bc-typing 1.2s ease-in-out infinite;
|
|
829
|
+
}
|
|
830
|
+
.typing i:nth-child(2) { animation-delay: .15s; }
|
|
831
|
+
.typing i:nth-child(3) { animation-delay: .3s; }
|
|
832
|
+
.typing-row .msg-when { padding-top: 4px; }
|
|
608
833
|
|
|
609
834
|
.att { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; }
|
|
610
835
|
.bubble.silent .att { margin-top: 0; }
|
|
611
836
|
.att img {
|
|
612
|
-
display: block; max-width: 100%; max-height: 190px; border-radius:
|
|
837
|
+
display: block; max-width: 100%; max-height: 190px; border-radius: var(--bc-r-md);
|
|
613
838
|
border: 1px solid var(--bc-border); object-fit: cover; cursor: zoom-in;
|
|
614
839
|
}
|
|
615
840
|
.att audio { width: 232px; max-width: 100%; height: 34px; }
|
|
616
|
-
.att video { max-width: 100%; max-height: 190px; border-radius:
|
|
841
|
+
.att video { max-width: 100%; max-height: 190px; border-radius: var(--bc-r-md); }
|
|
617
842
|
.att .file {
|
|
618
843
|
display: flex; align-items: center; gap: 8px; text-decoration: none;
|
|
619
|
-
padding: 8px 10px; border-radius:
|
|
844
|
+
padding: 8px 10px; border-radius: 10px; font-size: 12px;
|
|
620
845
|
background: var(--bc-elevated); border: 1px solid var(--bc-border); color: var(--bc-fg);
|
|
621
846
|
}
|
|
622
847
|
.att .file:hover { border-color: var(--bc-accent); }
|
|
623
848
|
.att .file .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
624
849
|
.att .file .sz { flex: none; color: var(--bc-faint); font-size: 11px; }
|
|
625
850
|
|
|
626
|
-
/*
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
.
|
|
631
|
-
.
|
|
851
|
+
/* ---------- the assistant's live card ----------
|
|
852
|
+
A status header pinned between the report and the conversation: where the
|
|
853
|
+
investigation is, as words and as a stepper. It does not scroll with the
|
|
854
|
+
thread, because "is anything happening?" is the question on screen the
|
|
855
|
+
whole time. */
|
|
856
|
+
.assistant {
|
|
857
|
+
flex: none; position: relative; overflow: hidden;
|
|
858
|
+
margin: 10px 12px 0; padding: 12px 14px 12px;
|
|
859
|
+
border-radius: var(--bc-r-lg); border: 1px solid var(--bc-border);
|
|
860
|
+
background: var(--bc-subtle);
|
|
861
|
+
transition: background 400ms ease, border-color 400ms ease;
|
|
862
|
+
}
|
|
863
|
+
.assistant.analyzing, .assistant.confirming, .assistant.needs_info {
|
|
864
|
+
background: var(--bc-accent-soft); border-color: var(--bc-accent-line);
|
|
865
|
+
}
|
|
866
|
+
.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)); }
|
|
867
|
+
/* On a call: the call's own card is what they are watching. This one states
|
|
868
|
+
what is happening and stops moving — no spinner, no travelling bar. */
|
|
869
|
+
.assistant.calling { background: var(--bc-bg); }
|
|
870
|
+
.assistant.calling .as-progress i { transition: none; }
|
|
871
|
+
.as-body.entering { animation: bc-rise 360ms var(--bc-ease) both; }
|
|
872
|
+
.as-top { display: flex; gap: 11px; align-items: center; }
|
|
873
|
+
.as-orb {
|
|
874
|
+
flex: none; width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center;
|
|
875
|
+
color: var(--bc-on-accent); font-size: 15px;
|
|
876
|
+
background: linear-gradient(140deg, var(--bc-accent), color-mix(in srgb, var(--bc-accent) 60%, #fff));
|
|
877
|
+
}
|
|
878
|
+
.assistant.resolved .as-orb { background: var(--bc-ok); }
|
|
879
|
+
.assistant.reviewing .as-orb { background: color-mix(in srgb, var(--bc-fg) 55%, var(--bc-bg)); }
|
|
880
|
+
.as-spin { width: 16px; height: 16px; border-radius: 50%; border: 2px solid rgba(255,255,255,.4); border-top-color: #fff; animation: as-rot .8s linear infinite; }
|
|
632
881
|
@keyframes as-rot { to { transform: rotate(360deg); } }
|
|
633
|
-
.as-
|
|
634
|
-
.
|
|
635
|
-
|
|
636
|
-
.as-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
.as-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
882
|
+
.as-text { min-width: 0; flex: 1; }
|
|
883
|
+
.as-title { font-weight: 650; font-size: 13.5px; letter-spacing: -.01em; color: var(--bc-fg); }
|
|
884
|
+
.as-detail { font-size: 12px; line-height: 1.45; color: var(--bc-muted); margin-top: 2px; }
|
|
885
|
+
.as-progress {
|
|
886
|
+
height: 4px; margin: 12px 0 0; border-radius: 999px; overflow: hidden;
|
|
887
|
+
background: color-mix(in srgb, var(--bc-fg) 9%, transparent);
|
|
888
|
+
}
|
|
889
|
+
.as-progress i {
|
|
890
|
+
display: block; height: 100%; border-radius: inherit; background: var(--bc-accent);
|
|
891
|
+
transition: width 700ms var(--bc-ease);
|
|
892
|
+
}
|
|
893
|
+
.assistant.resolved .as-progress i { background: var(--bc-ok); }
|
|
894
|
+
.assistant.analyzing .as-progress i {
|
|
895
|
+
background: linear-gradient(90deg, var(--bc-accent) 0%, color-mix(in srgb, var(--bc-accent) 45%, #fff) 50%, var(--bc-accent) 100%);
|
|
896
|
+
background-size: 200% 100%; animation: bc-shimmer 1.6s linear infinite;
|
|
897
|
+
}
|
|
898
|
+
.as-steps {
|
|
899
|
+
list-style: none; display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px;
|
|
900
|
+
margin: 8px 0 0; padding: 0; font-size: 10.5px; color: var(--bc-faint);
|
|
901
|
+
}
|
|
902
|
+
.as-steps li { display: flex; align-items: center; gap: 5px; min-width: 0; white-space: nowrap; }
|
|
903
|
+
.as-steps li .lbl { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
|
904
|
+
.as-steps li .dot { flex: none; width: 8px; height: 8px; border-radius: 50%; border: 1.5px solid currentColor; }
|
|
905
|
+
.as-steps li.done { color: var(--bc-muted); }
|
|
906
|
+
.as-steps li.done .dot { background: var(--bc-accent); border-color: var(--bc-accent); }
|
|
907
|
+
.assistant.resolved .as-steps li.done .dot { background: var(--bc-ok); border-color: var(--bc-ok); }
|
|
908
|
+
.as-steps li.active { color: var(--bc-fg); font-weight: 650; }
|
|
909
|
+
.as-steps li.active .dot { border-color: var(--bc-accent); background: var(--bc-bg); animation: as-pulse 1.2s ease-in-out infinite; }
|
|
644
910
|
@keyframes as-pulse { 50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--bc-accent) 25%, transparent); } }
|
|
645
|
-
@media (prefers-reduced-motion: reduce) { .as-spin, .as-steps li.active
|
|
911
|
+
@media (prefers-reduced-motion: reduce) { .as-spin, .as-steps li.active .dot, .assistant.analyzing .as-progress i, .typing i { animation: none; } }
|
|
646
912
|
|
|
647
913
|
/* Quick answers under the confirm question — only on the latest message. */
|
|
648
|
-
.msg-row .quick { display: none; gap: 6px; flex-wrap: wrap; margin-top:
|
|
914
|
+
.msg-row .quick { display: none; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
|
|
649
915
|
.msg-row:last-child .quick { display: flex; }
|
|
650
916
|
.quick button {
|
|
651
|
-
|
|
652
|
-
|
|
917
|
+
display: inline-flex; align-items: center; gap: 6px;
|
|
918
|
+
font: inherit; font-size: 13px; font-weight: 600; padding: 8px 14px; border-radius: 999px; cursor: pointer;
|
|
919
|
+
border: 1px solid var(--bc-accent); background: var(--bc-bg); color: var(--bc-accent);
|
|
920
|
+
transition: background 160ms, color 160ms, transform 160ms var(--bc-ease);
|
|
921
|
+
animation: bc-item-in 300ms var(--bc-ease) both;
|
|
653
922
|
}
|
|
654
|
-
.quick button:
|
|
923
|
+
.quick button:first-child { background: var(--bc-accent); color: var(--bc-on-accent); }
|
|
924
|
+
.quick button:nth-child(2) { animation-delay: 60ms; }
|
|
925
|
+
.quick button svg { font-size: 13px; }
|
|
926
|
+
.quick button:hover { transform: translateY(-1px); background: var(--bc-accent-soft); }
|
|
927
|
+
.quick button:first-child:hover { background: var(--bc-accent); filter: brightness(1.07); }
|
|
655
928
|
|
|
656
929
|
/* ---------- the composer ---------- */
|
|
657
930
|
.composer {
|
|
658
|
-
|
|
659
|
-
padding: 10px
|
|
931
|
+
flex: none; position: relative;
|
|
932
|
+
padding: 10px 12px max(12px, env(safe-area-inset-bottom, 0px));
|
|
660
933
|
/* The panel's own background, not --bc-elevated: the two are the same white
|
|
661
934
|
in light mode but differ in dark, where a raised composer reads as a band
|
|
662
935
|
laid over the panel rather than the foot of it. */
|
|
663
936
|
background: var(--bc-bg);
|
|
664
937
|
border-top: 1px solid var(--bc-border);
|
|
665
938
|
}
|
|
666
|
-
|
|
939
|
+
/* One rounded field holding the tools, the text and the send button — the
|
|
940
|
+
shape every chat box has, so nobody has to look for where to type. */
|
|
941
|
+
.composer .row {
|
|
942
|
+
display: flex; align-items: flex-end; gap: 4px;
|
|
943
|
+
padding: 5px; border-radius: 22px;
|
|
944
|
+
background: var(--bc-subtle); border: 1px solid var(--bc-border);
|
|
945
|
+
transition: border-color 160ms, box-shadow 160ms;
|
|
946
|
+
}
|
|
947
|
+
.composer .row:focus-within { border-color: var(--bc-accent); box-shadow: var(--bc-ring); }
|
|
667
948
|
.composer textarea {
|
|
668
|
-
flex: 1; min-height:
|
|
669
|
-
font: inherit; font-size:
|
|
670
|
-
color: var(--bc-fg); background:
|
|
671
|
-
border: 1px solid var(--bc-border); border-radius: 10px;
|
|
949
|
+
flex: 1; min-width: 0; min-height: 34px; max-height: 120px; resize: none; padding: 7px 6px;
|
|
950
|
+
font: inherit; font-size: 13.5px; line-height: 1.45;
|
|
951
|
+
color: var(--bc-fg); background: transparent; border: 0; border-radius: 0;
|
|
672
952
|
}
|
|
673
|
-
.composer textarea:focus {
|
|
674
|
-
.composer .tools { display: flex; gap:
|
|
953
|
+
.composer textarea:hover, .composer textarea:focus { border: 0; box-shadow: none; outline: none; }
|
|
954
|
+
.composer .tools { display: flex; gap: 0; grid-template-columns: none; }
|
|
675
955
|
.composer .tool {
|
|
676
|
-
width: 34px; height: 34px; display: grid; place-items: center; cursor: pointer;
|
|
677
|
-
border-radius:
|
|
956
|
+
width: 34px; height: 34px; min-height: 0; padding: 0; display: grid; place-items: center; cursor: pointer;
|
|
957
|
+
border-radius: 50%; border: 0; background: none; font-size: 17px;
|
|
678
958
|
color: var(--bc-muted);
|
|
679
959
|
}
|
|
680
|
-
.composer .tool
|
|
960
|
+
.composer .tool svg { font-size: 17px; }
|
|
961
|
+
.composer .tool:hover:not(:disabled) { background: color-mix(in srgb, var(--bc-fg) 8%, transparent); color: var(--bc-fg); border: 0; transform: none; }
|
|
962
|
+
.composer .tool:hover:not(:disabled) svg { color: var(--bc-fg); }
|
|
681
963
|
.composer .tool:disabled { opacity: .4; cursor: default; }
|
|
682
964
|
.composer .tool.recording { color: var(--bc-danger); background: color-mix(in srgb, var(--bc-danger) 12%, transparent); }
|
|
965
|
+
.composer .tool.recording svg { color: var(--bc-danger); }
|
|
683
966
|
.composer .send {
|
|
684
|
-
width: 34px; height: 34px; flex: none; border-radius:
|
|
685
|
-
background: var(--bc-accent); border:
|
|
967
|
+
width: 34px; height: 34px; flex: none; border-radius: 50%; cursor: pointer; font-size: 16px;
|
|
968
|
+
background: var(--bc-accent); border: 0; color: var(--bc-on-accent);
|
|
686
969
|
display: grid; place-items: center;
|
|
970
|
+
transition: transform 160ms var(--bc-ease), opacity 160ms, filter 160ms;
|
|
687
971
|
}
|
|
688
|
-
.composer .send:disabled {
|
|
972
|
+
.composer .send:hover:not(:disabled) { filter: brightness(1.08); transform: scale(1.05); }
|
|
973
|
+
.composer .send:disabled { opacity: .35; cursor: default; background: var(--bc-faint); }
|
|
689
974
|
.composer .staged { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 0 8px; }
|
|
690
975
|
.composer .chip {
|
|
691
|
-
display: flex; align-items: center; gap: 6px; max-width:
|
|
692
|
-
padding: 5px 7px; border-radius:
|
|
976
|
+
display: flex; align-items: center; gap: 6px; max-width: 200px;
|
|
977
|
+
padding: 5px 7px 5px 10px; border-radius: 999px; font-size: var(--bc-fs-xs);
|
|
693
978
|
background: var(--bc-subtle); border: 1px solid var(--bc-border); color: var(--bc-muted);
|
|
979
|
+
animation: bc-item-in 240ms var(--bc-ease) both;
|
|
694
980
|
}
|
|
695
|
-
.composer .chip .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
981
|
+
.composer .chip .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--bc-fg); }
|
|
696
982
|
.composer .chip button {
|
|
697
|
-
flex: none; width:
|
|
698
|
-
border: 0; border-radius:
|
|
983
|
+
flex: none; width: 18px; height: 18px; display: grid; place-items: center;
|
|
984
|
+
border: 0; border-radius: 50%; background: none; color: var(--bc-faint); cursor: pointer;
|
|
699
985
|
}
|
|
700
986
|
.composer .chip button:hover { color: var(--bc-danger); }
|
|
701
987
|
.composer .chip.uploading { border-style: dashed; }
|
|
702
|
-
.composer .note { padding: 6px
|
|
988
|
+
.composer .note { padding: 6px 6px 0; font-size: var(--bc-fs-xs); color: var(--bc-muted); }
|
|
703
989
|
.composer .note.err { color: var(--bc-danger); }
|
|
704
990
|
.reccount { font-variant-numeric: tabular-nums; }
|
|
705
991
|
|
|
706
992
|
/* Status carries the only colour in this list, so it is the thing the eye
|
|
707
993
|
lands on — which is what someone opening this screen came to check. */
|
|
708
994
|
.pill {
|
|
709
|
-
flex: none;
|
|
710
|
-
|
|
711
|
-
|
|
995
|
+
flex: none; display: inline-flex; align-items: center; gap: 5px;
|
|
996
|
+
font-size: 11px; font-weight: 650;
|
|
997
|
+
padding: 3px 9px; border-radius: 999px;
|
|
998
|
+
color: var(--bc-muted); background: var(--bc-subtle);
|
|
999
|
+
}
|
|
1000
|
+
.pill::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
|
1001
|
+
.pill.open { color: #c2410c; background: color-mix(in srgb, #f59e0b 14%, var(--bc-bg)); }
|
|
1002
|
+
.pill.in_progress { color: #1d6fd1; background: color-mix(in srgb, #3b82f6 14%, var(--bc-bg)); }
|
|
1003
|
+
.pill.resolved { color: #0f8a5a; background: color-mix(in srgb, #10b981 14%, var(--bc-bg)); }
|
|
1004
|
+
.pill.closed { color: var(--bc-muted); }
|
|
1005
|
+
:host(.bc-dark) .pill.open { color: #fbbf24; }
|
|
1006
|
+
:host(.bc-dark) .pill.in_progress { color: #7cb8ff; }
|
|
1007
|
+
:host(.bc-dark) .pill.resolved { color: #4ade9a; }
|
|
1008
|
+
@media (prefers-color-scheme: dark) {
|
|
1009
|
+
:host(.bc-auto) .pill.open { color: #fbbf24; }
|
|
1010
|
+
:host(.bc-auto) .pill.in_progress { color: #7cb8ff; }
|
|
1011
|
+
:host(.bc-auto) .pill.resolved { color: #4ade9a; }
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/* ---------- does the fix work ---------- */
|
|
1015
|
+
.fix-verification {
|
|
1016
|
+
flex: none; margin: 10px 12px 0; padding: 12px 14px;
|
|
1017
|
+
border-radius: var(--bc-r-lg); border: 1px solid color-mix(in srgb, var(--bc-ok) 35%, var(--bc-border));
|
|
1018
|
+
background: color-mix(in srgb, var(--bc-ok) 8%, var(--bc-bg));
|
|
1019
|
+
font-size: var(--bc-fs-sm); color: var(--bc-fg);
|
|
1020
|
+
animation: bc-rise 280ms var(--bc-ease) both;
|
|
1021
|
+
}
|
|
1022
|
+
.fix-verification p { margin: 0 0 8px; font-weight: 620; }
|
|
1023
|
+
.fix-verification textarea { min-height: 56px; margin-bottom: 8px; font-size: 13px; background: var(--bc-bg); }
|
|
1024
|
+
.fix-verification button {
|
|
1025
|
+
padding: 7px 13px; font: inherit; font-size: 12.5px; font-weight: 620; border-radius: 999px; cursor: pointer;
|
|
1026
|
+
border: 1px solid var(--bc-border); background: var(--bc-bg); color: var(--bc-fg);
|
|
712
1027
|
}
|
|
713
|
-
.
|
|
714
|
-
.
|
|
715
|
-
.
|
|
716
|
-
.pill.closed { color: var(--bc-faint); }
|
|
1028
|
+
.fix-verification button[data-result="CONFIRMED"] { background: var(--bc-ok); border-color: var(--bc-ok); color: #fff; }
|
|
1029
|
+
.fix-verification .feedback-status { margin: 6px 0 0; font-weight: 450; color: var(--bc-danger); font-size: var(--bc-fs-xs); }
|
|
1030
|
+
.fix-verification .feedback-status:empty { display: none; }
|
|
717
1031
|
|
|
718
1032
|
/* A call in progress, in place of the form.
|
|
719
1033
|
---------------------------------------------------------------------------
|
|
720
1034
|
Deliberately not styled as a chat. Nothing arrives here to read — the
|
|
721
1035
|
conversation is happening out loud — so this is a state, a line of
|
|
722
1036
|
reassurance about the report, and a way to hang up. */
|
|
723
|
-
.call { padding:
|
|
724
|
-
.call-head
|
|
725
|
-
.call-head
|
|
1037
|
+
.call { display: flex; flex-direction: column; padding: 26px 0 0; animation: bc-fade-in 240ms ease both; }
|
|
1038
|
+
.call-head, .call-note { padding: 0 var(--bc-pad); }
|
|
1039
|
+
.call-head { display: flex; align-items: center; gap: 9px; margin-bottom: 8px; }
|
|
1040
|
+
.call-head strong { font-size: 15.5px; font-weight: 680; letter-spacing: -.01em; }
|
|
726
1041
|
.call-dot {
|
|
727
|
-
width:
|
|
1042
|
+
width: 9px; height: 9px; border-radius: 50%; background: var(--bc-accent);
|
|
728
1043
|
animation: bc-call-pulse 1.6s ease-in-out infinite;
|
|
729
1044
|
}
|
|
730
1045
|
.call-dot.off { background: var(--bc-faint); animation: none; }
|
|
731
1046
|
@keyframes bc-call-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } }
|
|
732
1047
|
/* Said while it is still ringing, because that is when somebody is deciding
|
|
733
1048
|
whether it is worth waiting. */
|
|
734
|
-
.call-note { margin: 0 0
|
|
1049
|
+
.call-note { margin: 0 0 18px; font-size: 13px; color: var(--bc-muted); line-height: 1.55; }
|
|
735
1050
|
|
|
736
1051
|
/* ---------- the same call, on top of the report it is about ---------- */
|
|
737
1052
|
/* A bar rather than its own screen, unlike the one the composer opens. The
|
|
738
1053
|
thread underneath is exactly what both people are talking about, so taking it
|
|
739
1054
|
off screen would be taking away the only shared reference. */
|
|
740
1055
|
.livecall {
|
|
1056
|
+
flex: none;
|
|
741
1057
|
display: flex; align-items: center; gap: 9px;
|
|
742
|
-
margin: 12px 0
|
|
743
|
-
border: 1px solid var(--bc-
|
|
744
|
-
background: var(--bc-
|
|
1058
|
+
margin: 10px 12px 0; padding: 9px 11px;
|
|
1059
|
+
border: 1px solid var(--bc-accent-line); border-radius: var(--bc-r-md);
|
|
1060
|
+
background: var(--bc-accent-soft); font-size: var(--bc-fs-sm); color: var(--bc-fg);
|
|
1061
|
+
animation: bc-rise 240ms var(--bc-ease) both;
|
|
745
1062
|
}
|
|
746
1063
|
.livecall .who { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1064
|
+
.livecall .call-kept { font-size: 11px; opacity: .75; }
|
|
747
1065
|
.livecall .secs { font-variant-numeric: tabular-nums; color: var(--bc-muted); }
|
|
748
1066
|
.livecall .act {
|
|
749
|
-
padding: 5px
|
|
750
|
-
background: var(--bc-
|
|
751
|
-
font: inherit; font-size: 12px; cursor: pointer;
|
|
1067
|
+
padding: 5px 11px; border-radius: 999px; border: 1px solid var(--bc-border);
|
|
1068
|
+
background: var(--bc-bg); color: var(--bc-fg);
|
|
1069
|
+
font: inherit; font-size: 12px; font-weight: 600; cursor: pointer;
|
|
752
1070
|
}
|
|
753
1071
|
.livecall .act:hover { background: var(--bc-subtle); }
|
|
754
1072
|
.livecall .act.danger { border-color: transparent; background: var(--bc-danger); color: #fff; }
|
|
755
|
-
.livecall.over { color: var(--bc-muted); }
|
|
1073
|
+
.livecall.over { color: var(--bc-muted); background: var(--bc-subtle); border-color: var(--bc-border); }
|
|
756
1074
|
.call-note code { font-size: 11px; }
|
|
757
1075
|
@media (prefers-reduced-motion: reduce) { .call-dot { animation: none; } }
|
|
758
1076
|
|
|
759
|
-
.done
|
|
1077
|
+
/* div.done, not .done: the assistant's stepper marks finished steps li.done,
|
|
1078
|
+
and this screen's padding was landing on every one of them. */
|
|
1079
|
+
div.done { padding: 44px 26px 44px; text-align: center; animation: bc-fade-in 240ms ease both; }
|
|
760
1080
|
.done .tick {
|
|
761
|
-
width:
|
|
762
|
-
display: grid; place-items: center; font-size:
|
|
1081
|
+
width: 56px; height: 56px; margin: 0 auto 16px; border-radius: 50%;
|
|
1082
|
+
display: grid; place-items: center; font-size: 26px;
|
|
763
1083
|
background: color-mix(in srgb, var(--bc-ok) 14%, transparent); color: var(--bc-ok);
|
|
1084
|
+
box-shadow: 0 0 0 8px color-mix(in srgb, var(--bc-ok) 6%, transparent);
|
|
764
1085
|
animation: bc-pop 420ms var(--bc-ease) both;
|
|
765
1086
|
}
|
|
766
|
-
.done h3 { margin: 0 0
|
|
767
|
-
.done p { margin: 0; font-size:
|
|
768
|
-
.done code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
|
|
1087
|
+
.done h3 { margin: 0 0 6px; font-size: 17px; font-weight: 680; letter-spacing: -.015em; }
|
|
1088
|
+
.done p { margin: 0; font-size: 13px; line-height: 1.5; color: var(--bc-muted); }
|
|
1089
|
+
.done code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; padding: 1px 5px; border-radius: 5px; background: var(--bc-subtle); color: var(--bc-fg); }
|
|
769
1090
|
/* The likely wait. Set apart from the reference line above it by a rule rather
|
|
770
1091
|
than by weight: one is bookkeeping and the other is the answer to the
|
|
771
1092
|
question the reporter actually has, and they should not read as one
|
|
772
1093
|
paragraph. */
|
|
773
1094
|
.done .eta {
|
|
774
|
-
margin:
|
|
775
|
-
padding-top:
|
|
776
|
-
border-top: 1px solid var(--bc-
|
|
1095
|
+
margin: 14px auto 0;
|
|
1096
|
+
padding-top: 12px;
|
|
1097
|
+
border-top: 1px solid var(--bc-border);
|
|
777
1098
|
max-width: 30ch;
|
|
778
1099
|
line-height: 1.5;
|
|
779
1100
|
}
|
|
780
|
-
.done .eta strong { color: var(--bc-
|
|
1101
|
+
.done .eta strong { color: var(--bc-fg); font-weight: 620; }
|
|
781
1102
|
/* The same figure on the report the reporter came back to. Sits under the
|
|
782
1103
|
status pill in the header block, muted — it is context, not the headline the
|
|
783
1104
|
status is. */
|
|
784
1105
|
.report-head .eta-line {
|
|
785
1106
|
grid-column: 1 / -1;
|
|
786
|
-
font-size:
|
|
1107
|
+
font-size: var(--bc-fs-xs);
|
|
787
1108
|
color: var(--bc-muted);
|
|
788
1109
|
margin-top: 2px;
|
|
789
1110
|
}
|
|
790
1111
|
|
|
791
1112
|
/* ---------- recording bar ---------- */
|
|
792
1113
|
.recbar {
|
|
793
|
-
position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%);
|
|
1114
|
+
position: fixed; left: 50%; bottom: calc(24px + env(safe-area-inset-bottom, 0px)); transform: translateX(-50%);
|
|
794
1115
|
z-index: 2147483002;
|
|
795
1116
|
display: flex; align-items: center; gap: 12px;
|
|
796
|
-
padding:
|
|
1117
|
+
padding: 8px 8px 8px 16px; border-radius: 999px;
|
|
797
1118
|
background: var(--bc-elevated); color: var(--bc-fg);
|
|
798
1119
|
border: 1px solid var(--bc-border); box-shadow: var(--bc-shadow);
|
|
799
1120
|
animation: bc-item-in 300ms var(--bc-ease) both;
|
|
800
1121
|
}
|
|
801
1122
|
.recbar .dot { width: 9px; height: 9px; border-radius: 50%; background: var(--bc-danger); animation: bc-pulse 1.4s ease-in-out infinite; }
|
|
802
|
-
.recbar .time { font-variant-numeric: tabular-nums; font-size: 13px; font-weight:
|
|
1123
|
+
.recbar .time { font-variant-numeric: tabular-nums; font-size: 13px; font-weight: 650; min-width: 38px; }
|
|
803
1124
|
.recbar .hint { font-size: 12px; color: var(--bc-muted); }
|
|
804
|
-
.recbar button { padding:
|
|
1125
|
+
.recbar button { height: 34px; padding: 0 14px; font-size: 12.5px; font-weight: 620; border-radius: 999px; cursor: pointer; }
|
|
805
1126
|
.recbar .stop { background: var(--bc-danger); border: 1px solid var(--bc-danger); color: #fff; }
|
|
806
1127
|
.recbar .stop:hover { filter: brightness(1.08); }
|
|
807
1128
|
|
|
808
|
-
@media (max-width: 520px) {
|
|
809
|
-
.panel { width: calc(100vw - 24px); max-width: none; }
|
|
810
|
-
.scrim.at-right .panel, .scrim.at-left .panel { left: 12px; right: 12px; }
|
|
811
|
-
.recbar .hint { display: none; }
|
|
812
|
-
}
|
|
813
|
-
|
|
814
1129
|
/* ---------- phones ----------------------------------------------------------
|
|
815
1130
|
This widget runs inside a shadow root on somebody else's site, so its own
|
|
816
1131
|
stylesheet is the whole story: the host page cannot correct any of this, and
|
|
@@ -835,6 +1150,32 @@ button.primary .spinner { border-color: rgba(255,255,255,.4); border-top-color:
|
|
|
835
1150
|
padding-left: env(safe-area-inset-left, 0px);
|
|
836
1151
|
}
|
|
837
1152
|
|
|
1153
|
+
/* Short desktop windows: the chat height gives way to the space there is. */
|
|
1154
|
+
@media (max-height: 820px) { .panel.is-chat { height: calc(100vh - var(--bc-dock-h, 84px) - 52px); } }
|
|
1155
|
+
|
|
1156
|
+
/* A phone gets the whole screen: a sheet that slides up from the bottom, with
|
|
1157
|
+
the notch and the home indicator kept clear. The dock steps aside while it
|
|
1158
|
+
is open — the sheet has its own close button, and a launcher floating over
|
|
1159
|
+
the send button would be in the way of the one thing that matters. */
|
|
1160
|
+
@media (max-width: 480px) {
|
|
1161
|
+
.scrim { background: rgba(10,12,16,.4); }
|
|
1162
|
+
.panel, .scrim.at-right .panel, .scrim.at-left .panel, .scrim.at-bottom .panel, .scrim.at-top .panel {
|
|
1163
|
+
inset: 0; width: 100%; max-width: none; height: 100%; max-height: none;
|
|
1164
|
+
border-radius: 0; border: 0;
|
|
1165
|
+
animation: bc-sheet-in 380ms var(--bc-ease) both;
|
|
1166
|
+
}
|
|
1167
|
+
@supports (height: 100dvh) { .panel { height: 100dvh; max-height: none; } }
|
|
1168
|
+
.panel.is-chat { height: 100%; }
|
|
1169
|
+
.scrim.closing .panel { animation: bc-sheet-out 200ms ease both; }
|
|
1170
|
+
.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)); }
|
|
1171
|
+
.foot, .powered { padding-bottom: max(10px, env(safe-area-inset-bottom, 0px)); }
|
|
1172
|
+
.foot + .powered { padding-bottom: max(9px, env(safe-area-inset-bottom, 0px)); }
|
|
1173
|
+
.foot:has(+ .powered) { padding-bottom: 12px; }
|
|
1174
|
+
.dock.over { display: none; }
|
|
1175
|
+
.recbar .hint { display: none; }
|
|
1176
|
+
.msg-row, .typing-row { max-width: 92%; }
|
|
1177
|
+
}
|
|
1178
|
+
|
|
838
1179
|
@media (pointer: coarse) {
|
|
839
1180
|
/* iOS zooms the whole page when a field it focuses is under 16px, and does
|
|
840
1181
|
not zoom back out. On the host's site that is not the widget's page to
|
|
@@ -846,47 +1187,33 @@ button.primary .spinner { border-color: rgba(255,255,255,.4); border-top-color:
|
|
|
846
1187
|
/* 44px is the floor a thumb hits reliably. Every control in this form was
|
|
847
1188
|
under it, on the one surface in the product designed for someone who is
|
|
848
1189
|
not an operator and gets no second attempt. */
|
|
849
|
-
.
|
|
850
|
-
.
|
|
851
|
-
.
|
|
852
|
-
|
|
1190
|
+
.seg button { min-height: 44px; }
|
|
1191
|
+
.tool { min-height: 56px; }
|
|
1192
|
+
button.primary, button.ghost { height: 44px; }
|
|
1193
|
+
.composer .send, .composer .tool { width: 40px; height: 40px; min-height: 0; }
|
|
853
1194
|
textarea { min-height: 104px; }
|
|
1195
|
+
.composer textarea { min-height: 40px; }
|
|
854
1196
|
|
|
855
|
-
/* The theme switch and the close button are
|
|
1197
|
+
/* The theme switch and the close button are small glyphs whose boxes cannot
|
|
856
1198
|
grow without moving the header text beside them, so each keeps its size
|
|
857
1199
|
and gets an invisible target drawn around it instead. */
|
|
858
|
-
.themer button, .head .icon-btn, .thumb .
|
|
859
|
-
.
|
|
1200
|
+
.themer button, .head .icon-btn, .thumb .drop, .brand-credit { position: relative; }
|
|
1201
|
+
.thumb .drop { position: absolute; opacity: 1; }
|
|
1202
|
+
.themer button::after, .head .icon-btn::after, .thumb .drop::after, .brand-credit::after {
|
|
860
1203
|
content: '';
|
|
861
1204
|
position: absolute;
|
|
862
|
-
inset: -
|
|
1205
|
+
inset: -8px;
|
|
863
1206
|
}
|
|
864
|
-
/* The close button is the one people reach for in a hurry, and it sits in
|
|
865
|
-
the corner where a thumb is least accurate. */
|
|
866
|
-
.head .icon-btn::after { inset: -12px; }
|
|
867
1207
|
/* The themer's three buttons sit side by side, so their expanders would
|
|
868
1208
|
overlap each other without a gap to grow into. */
|
|
869
1209
|
.themer { gap: 4px; }
|
|
870
|
-
|
|
871
|
-
/* Thumb-sized controls make the form taller, which on a short phone pushed
|
|
872
|
-
"Send report" below the fold of a panel that scrolls — so the one button
|
|
873
|
-
the whole widget exists for was reachable only by scrolling past the
|
|
874
|
-
attachment row. It stays on screen instead. */
|
|
875
|
-
.foot {
|
|
876
|
-
position: sticky;
|
|
877
|
-
bottom: 0;
|
|
878
|
-
margin-top: 16px;
|
|
879
|
-
/* The panel scrolls under this, so the bar cannot be translucent. */
|
|
880
|
-
background: var(--bc-subtle);
|
|
881
|
-
padding-bottom: max(14px, env(safe-area-inset-bottom, 0px));
|
|
882
|
-
}
|
|
883
1210
|
}
|
|
884
1211
|
|
|
885
1212
|
/* Hover is a lie on a touchscreen: it latches on tap and stays. The launcher's
|
|
886
1213
|
lift in particular was left applied after the panel closed. */
|
|
887
1214
|
@media (hover: none) {
|
|
888
|
-
.launcher:hover { transform: none; filter: none;
|
|
889
|
-
.tool:hover, .themer button:hover {
|
|
1215
|
+
.launcher:hover { transform: none; filter: none; }
|
|
1216
|
+
.tool:hover:not(:disabled), .themer button:hover { transform: none; }
|
|
890
1217
|
}
|
|
891
1218
|
`;
|
|
892
1219
|
|
|
@@ -934,7 +1261,8 @@ export function mountWidget(client) {
|
|
|
934
1261
|
const launcher = document.createElement('button');
|
|
935
1262
|
launcher.className = 'launcher';
|
|
936
1263
|
launcher.type = 'button';
|
|
937
|
-
launcher.innerHTML =
|
|
1264
|
+
launcher.innerHTML = `<span class="ico" aria-hidden="true"><span class="when-closed">${ICONS.chat}</span>`
|
|
1265
|
+
+ `<span class="when-open">${ICONS.chevronDown}</span></span><span>Report a bug</span>`;
|
|
938
1266
|
launcher.setAttribute('aria-haspopup', 'dialog');
|
|
939
1267
|
launcher.setAttribute('aria-expanded', 'false');
|
|
940
1268
|
|
|
@@ -962,7 +1290,14 @@ export function mountWidget(client) {
|
|
|
962
1290
|
root.appendChild(dock);
|
|
963
1291
|
|
|
964
1292
|
document.body.appendChild(host);
|
|
965
|
-
|
|
1293
|
+
// Integration hook: BugCatch.open() / close() / isOpen() drive the same
|
|
1294
|
+
// launcher a person would press, so there is one way in to the panel.
|
|
1295
|
+
client.widgetControls = {
|
|
1296
|
+
open: () => { if (!open) launcher.click(); },
|
|
1297
|
+
close: () => { open?.close(); },
|
|
1298
|
+
isOpen: () => Boolean(open),
|
|
1299
|
+
};
|
|
1300
|
+
return () => { open?.destroy(); host.remove(); client.widgetControls = null; };
|
|
966
1301
|
}
|
|
967
1302
|
|
|
968
1303
|
/** Normalises a configured theme to one of the three the switch offers. */
|
|
@@ -998,26 +1333,25 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
998
1333
|
scrim.style.setProperty('--bc-dock-h', `${Math.round(dock.getBoundingClientRect().height)}px`);
|
|
999
1334
|
scrim.innerHTML = `
|
|
1000
1335
|
<div class="panel" role="dialog" aria-modal="true" aria-labelledby="bc-h">
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
</div>
|
|
1007
|
-
<button class="icon-btn" type="button" data-act="close" aria-label="Close">${ICONS.close}</button>
|
|
1008
|
-
</div>
|
|
1336
|
+
${headHtml({
|
|
1337
|
+
id: 'bc-h',
|
|
1338
|
+
title: 'Tell us what went wrong',
|
|
1339
|
+
sub: "Our assistant starts looking right away. We attach the page details for you.",
|
|
1340
|
+
})}
|
|
1009
1341
|
<div class="body">
|
|
1010
|
-
<div class="slot"></div>
|
|
1342
|
+
<div class="slot" role="alert"></div>
|
|
1011
1343
|
<div class="whoami" hidden></div>
|
|
1012
1344
|
<div class="known" hidden></div>
|
|
1013
1345
|
<div class="compose">
|
|
1014
1346
|
<div class="field">
|
|
1015
1347
|
<label for="bc-title">What went wrong?</label>
|
|
1016
|
-
<input id="bc-title" type="text" placeholder="
|
|
1348
|
+
<input id="bc-title" type="text" placeholder="e.g. The checkout button does nothing" autocomplete="off"
|
|
1349
|
+
aria-describedby="bc-title-err" />
|
|
1350
|
+
<p class="field-err" id="bc-title-err" hidden></p>
|
|
1017
1351
|
</div>
|
|
1018
1352
|
<div class="field">
|
|
1019
|
-
<label for="bc-desc">
|
|
1020
|
-
<textarea id="bc-desc" placeholder="
|
|
1353
|
+
<label for="bc-desc">Anything else that helps <span class="opt">(optional)</span></label>
|
|
1354
|
+
<textarea id="bc-desc" placeholder="What you were doing, what you expected, what happened instead."></textarea>
|
|
1021
1355
|
</div>
|
|
1022
1356
|
<!-- Drawn only when this project asks for an identifier and the report
|
|
1023
1357
|
does not already carry one. Between the description and the
|
|
@@ -1025,7 +1359,7 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1025
1359
|
it is a question about what they just typed. -->
|
|
1026
1360
|
<div class="refs" hidden></div>
|
|
1027
1361
|
<div class="field">
|
|
1028
|
-
<label id="bc-prio-l">
|
|
1362
|
+
<label id="bc-prio-l">How much is it getting in your way?</label>
|
|
1029
1363
|
<div class="seg" role="group" aria-labelledby="bc-prio-l">
|
|
1030
1364
|
${PRIORITIES.map((p) => `
|
|
1031
1365
|
<button type="button" data-prio="${p.value}" aria-pressed="${p.value === priority}">${p.label}</button>
|
|
@@ -1034,11 +1368,14 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1034
1368
|
</div>
|
|
1035
1369
|
${canCapture ? `
|
|
1036
1370
|
<div class="field">
|
|
1037
|
-
<
|
|
1038
|
-
|
|
1371
|
+
<div class="field-head">
|
|
1372
|
+
<label id="bc-att-l">Show us <span class="opt">(optional)</span></label>
|
|
1373
|
+
<span class="count" aria-live="polite"></span>
|
|
1374
|
+
</div>
|
|
1375
|
+
<div class="tools" role="group" aria-labelledby="bc-att-l">
|
|
1039
1376
|
${canShoot ? `<button class="tool" type="button" data-act="shot">${ICONS.camera}Screenshot</button>` : ''}
|
|
1040
|
-
${canRecord ? `<button class="tool" type="button" data-act="rec">${ICONS.video}Record</button>` : ''}
|
|
1041
|
-
<button class="tool" type="button" data-act="file">${ICONS.paperclip}
|
|
1377
|
+
${canRecord ? `<button class="tool" type="button" data-act="rec">${ICONS.video}Record screen</button>` : ''}
|
|
1378
|
+
<button class="tool" type="button" data-act="file">${ICONS.paperclip}Upload file</button>
|
|
1042
1379
|
</div>
|
|
1043
1380
|
<div class="thumbs"></div>
|
|
1044
1381
|
<input type="file" accept="${REPORT_MIMES.join(',')}" multiple hidden />
|
|
@@ -1046,22 +1383,26 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1046
1383
|
</div>
|
|
1047
1384
|
</div>
|
|
1048
1385
|
<div class="foot">
|
|
1049
|
-
<
|
|
1050
|
-
<
|
|
1386
|
+
<span class="note" aria-live="polite"></span>
|
|
1387
|
+
${client.hasReports() ? '<button class="quiet" type="button" data-act="history">Your reports</button>' : ''}
|
|
1051
1388
|
<span class="spacer"></span>
|
|
1052
|
-
${client.hasReports() ? '<button class="ghost" type="button" data-act="history">Your reports</button>' : ''}
|
|
1053
|
-
<button class="ghost" type="button" data-act="cancel">Cancel</button>
|
|
1054
1389
|
<!-- Hidden until the API confirms this project can take a call, which
|
|
1055
1390
|
most cannot. Drawn here rather than as a second launcher because it
|
|
1056
1391
|
is the same errand as Send report — the difference is only whether
|
|
1057
1392
|
the reporter would rather say it than type it. -->
|
|
1058
|
-
<button class="ghost" type="button" data-act="call" hidden
|
|
1059
|
-
<button class="primary" type="button" data-act="send">Send report</button>
|
|
1393
|
+
<button class="ghost" type="button" data-act="call" hidden>${ICONS.phone}Talk to the team</button>
|
|
1394
|
+
<button class="primary" type="button" data-act="send">Send report ${ICONS.send}</button>
|
|
1395
|
+
</div>
|
|
1396
|
+
<div class="powered">Powered by
|
|
1397
|
+
<a class="brand-credit" href="https://bugcatch.sphoro.com" target="_blank" rel="noopener noreferrer" aria-label="BugCatch">${BRAND}</a>
|
|
1060
1398
|
</div>
|
|
1061
1399
|
</div>`;
|
|
1062
1400
|
|
|
1063
1401
|
const $ = (sel) => scrim.querySelector(sel);
|
|
1064
1402
|
const titleInput = $('#bc-title');
|
|
1403
|
+
// Held, not looked up: the history and report screens replace the panel's
|
|
1404
|
+
// markup, and a draft saved on the way out must still read what was typed.
|
|
1405
|
+
const descInput = $('#bc-desc');
|
|
1065
1406
|
const slot = $('.slot');
|
|
1066
1407
|
const thumbs = $('.thumbs');
|
|
1067
1408
|
const filePicker = scrim.querySelector('input[type=file]');
|
|
@@ -1086,8 +1427,8 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1086
1427
|
function persistDraft() {
|
|
1087
1428
|
clearTimeout(draftTimer);
|
|
1088
1429
|
if (!draftLoaded || draftSubmitted || cfg.drafts === false) return draftWrites;
|
|
1089
|
-
const value = titleInput.value ||
|
|
1090
|
-
title: titleInput.value, description:
|
|
1430
|
+
const value = titleInput.value || descInput.value || items.length ? {
|
|
1431
|
+
title: titleInput.value, description: descInput.value, priority,
|
|
1091
1432
|
items: items.map(({ blob, mime, kind }) => ({ blob, mime, kind })),
|
|
1092
1433
|
} : null;
|
|
1093
1434
|
draftWrites = draftWrites.catch(() => {}).then(() => draftOperation(savedDraftKey, value))
|
|
@@ -1107,10 +1448,10 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1107
1448
|
compose.inert = true;
|
|
1108
1449
|
readDraft(savedDraftKey).then((draft) => {
|
|
1109
1450
|
if (closed || !draft) return;
|
|
1110
|
-
draftStatus.innerHTML = '
|
|
1451
|
+
draftStatus.innerHTML = '<span>You have an unfinished report on this device.</span> <button type="button" data-resume>Resume report</button> <button type="button" data-discard>Discard</button>';
|
|
1111
1452
|
draftStatus.querySelector('[data-resume]').onclick = () => {
|
|
1112
1453
|
titleInput.value = draft.title || '';
|
|
1113
|
-
|
|
1454
|
+
descInput.value = draft.description || '';
|
|
1114
1455
|
priority = PRIORITIES.some((p) => p.value === draft.priority) ? draft.priority : 'MEDIUM';
|
|
1115
1456
|
scrim.querySelectorAll('[data-prio]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.prio === priority)));
|
|
1116
1457
|
for (const item of (draft.items || []).slice(0, cfg.maxAttachments ?? 5)) addItem(item);
|
|
@@ -1188,7 +1529,6 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1188
1529
|
document.addEventListener('keydown', onKeydown, true);
|
|
1189
1530
|
scrim.addEventListener('click', (e) => { if (e.target === scrim && !sending) close(); });
|
|
1190
1531
|
$('[data-act="close"]').addEventListener('click', close);
|
|
1191
|
-
$('[data-act="cancel"]').addEventListener('click', close);
|
|
1192
1532
|
|
|
1193
1533
|
/* ---------- severity ---------- */
|
|
1194
1534
|
|
|
@@ -1205,16 +1545,32 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1205
1545
|
/* ---------- attachments ---------- */
|
|
1206
1546
|
|
|
1207
1547
|
function say(text, kind = 'err') {
|
|
1208
|
-
slot.innerHTML = text
|
|
1548
|
+
slot.innerHTML = text
|
|
1549
|
+
? `<div class="msg ${kind}">${kind === 'err' ? ICONS.alert : ''}<span>${escapeHtml(text)}</span></div>`
|
|
1550
|
+
: '';
|
|
1209
1551
|
}
|
|
1210
1552
|
|
|
1553
|
+
// The one required field says what is missing right under itself, rather
|
|
1554
|
+
// than in a banner the reporter has to connect back to it.
|
|
1555
|
+
const titleErr = $('#bc-title-err');
|
|
1556
|
+
function flagTitle(text) {
|
|
1557
|
+
titleErr.hidden = !text;
|
|
1558
|
+
titleErr.innerHTML = text ? `${ICONS.alert}<span>${escapeHtml(text)}</span>` : '';
|
|
1559
|
+
titleInput.classList.toggle('invalid', Boolean(text));
|
|
1560
|
+
titleInput.setAttribute('aria-invalid', String(Boolean(text)));
|
|
1561
|
+
}
|
|
1562
|
+
titleInput.addEventListener('input', () => { if (titleInput.value.trim()) flagTitle(''); });
|
|
1563
|
+
|
|
1211
1564
|
function refreshSendState() {
|
|
1212
1565
|
const busy = items.some((it) => it.status === 'uploading');
|
|
1213
1566
|
sendBtn.disabled = sending || busy || items.some((it) => it.status === 'failed');
|
|
1214
1567
|
note.textContent = busy ? 'Waiting for the upload to finish…' : items.some((it) => it.status === 'failed') ? 'Retry or remove failed attachments before sending.' : '';
|
|
1568
|
+
const max = cfg.maxAttachments ?? 5;
|
|
1215
1569
|
scrim.querySelectorAll('.tool').forEach((t) => {
|
|
1216
|
-
t.disabled = sending || items.length >=
|
|
1570
|
+
t.disabled = sending || items.length >= max;
|
|
1217
1571
|
});
|
|
1572
|
+
const count = $('.field-head .count');
|
|
1573
|
+
if (count) count.textContent = items.length ? `${items.length} of ${max}` : '';
|
|
1218
1574
|
}
|
|
1219
1575
|
|
|
1220
1576
|
function drawThumb(item) {
|
|
@@ -1407,9 +1763,16 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1407
1763
|
// answer, is a reasonable thing to be asked. Most reporters never see it: the
|
|
1408
1764
|
// loan number was in the sentence.
|
|
1409
1765
|
let refConfig = { policy: 'off', fields: [] };
|
|
1410
|
-
let
|
|
1766
|
+
let refOpen = false;
|
|
1767
|
+
let refChoice = null; // which field they are answering; null = not picked yet
|
|
1411
1768
|
let refValue = '';
|
|
1412
1769
|
let refError = '';
|
|
1770
|
+
// What we read in their text but could not place for sure, offered as taps:
|
|
1771
|
+
// [{ value, kinds: [{ key, label }] }]. `refMode` says why the card is open —
|
|
1772
|
+
// 'confirm' when the text had two answers, 'suggest' when it had an unlabelled
|
|
1773
|
+
// one, 'ask' when it had none.
|
|
1774
|
+
let refCandidates = [];
|
|
1775
|
+
let refMode = 'ask';
|
|
1413
1776
|
// Set once they have answered or explicitly skipped, so Send stops asking.
|
|
1414
1777
|
let refSettled = false;
|
|
1415
1778
|
// Set once we have said "that does not look right" about the current value.
|
|
@@ -1420,13 +1783,18 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1420
1783
|
let refWarned = false;
|
|
1421
1784
|
|
|
1422
1785
|
client.referenceFields()
|
|
1423
|
-
.then((cfg) => { refConfig = cfg; })
|
|
1786
|
+
.then((cfg) => { refConfig = cfg; showFound(); })
|
|
1424
1787
|
.catch(() => { /* off is the safe answer to every failure */ });
|
|
1425
1788
|
|
|
1426
|
-
/** What the
|
|
1427
|
-
function
|
|
1428
|
-
if (refConfig.policy === 'off' || !refConfig.fields.length) return [];
|
|
1429
|
-
return
|
|
1789
|
+
/** What the text says: found (sure), conflicts (ask which), loose (offer). */
|
|
1790
|
+
function readText() {
|
|
1791
|
+
if (refConfig.policy === 'off' || !refConfig.fields.length) return { found: [], conflicts: [], loose: [] };
|
|
1792
|
+
return readReferences(refConfig.fields, [titleInput.value, descInput.value]);
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
/** Sure enough to send without a question: something found, nothing contested. */
|
|
1796
|
+
function textAnswers(read = readText()) {
|
|
1797
|
+
return read.found.length > 0 && read.conflicts.length === 0;
|
|
1430
1798
|
}
|
|
1431
1799
|
|
|
1432
1800
|
/** The answer to send, or an empty list. */
|
|
@@ -1435,35 +1803,135 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1435
1803
|
return [{ key: refChoice, value: refValue.trim().slice(0, 120) }];
|
|
1436
1804
|
}
|
|
1437
1805
|
|
|
1806
|
+
// A quiet line under the description while they type, so somebody who put
|
|
1807
|
+
// their loan number in the sentence can see it was read and does not go
|
|
1808
|
+
// looking for a box to type it into again.
|
|
1809
|
+
const foundNote = document.createElement('p');
|
|
1810
|
+
foundNote.className = 'ref-found';
|
|
1811
|
+
foundNote.setAttribute('aria-live', 'polite');
|
|
1812
|
+
foundNote.hidden = true;
|
|
1813
|
+
descInput.closest('.field').after(foundNote);
|
|
1814
|
+
let foundTimer;
|
|
1815
|
+
function showFound() {
|
|
1816
|
+
const read = readText();
|
|
1817
|
+
if (textAnswers(read)) {
|
|
1818
|
+
const list = read.found.slice(0, 2).map((r) => `${escapeHtml(r.label)} <b>${escapeHtml(shortId(r.value))}</b>`).join(', ');
|
|
1819
|
+
foundNote.innerHTML = `${ICONS.check}<span>Found your ${list} — no need to add it again.</span>`;
|
|
1820
|
+
foundNote.hidden = false;
|
|
1821
|
+
// They have answered in the text; a card still asking would be not listening.
|
|
1822
|
+
if (refOpen && !answeredReference().length) { refOpen = false; drawRefs(); }
|
|
1823
|
+
} else {
|
|
1824
|
+
foundNote.hidden = true;
|
|
1825
|
+
foundNote.innerHTML = '';
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
const queueFound = () => { clearTimeout(foundTimer); foundTimer = setTimeout(showFound, 250); };
|
|
1829
|
+
titleInput.addEventListener('input', queueFound);
|
|
1830
|
+
descInput.addEventListener('input', queueFound);
|
|
1831
|
+
|
|
1832
|
+
/** A UUID is long; the chip shows its ends, the title shows all of it. */
|
|
1833
|
+
function shortId(value) {
|
|
1834
|
+
return value.length > 20 ? `${value.slice(0, 8)}…${value.slice(-4)}` : value;
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
function candidateFor(value) {
|
|
1838
|
+
const v = value.trim().toLowerCase();
|
|
1839
|
+
return refCandidates.find((c) => c.value.toLowerCase() === v) || null;
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
/** Pick a candidate: its value, and its kind when only one kind fits. */
|
|
1843
|
+
function takeCandidate(c) {
|
|
1844
|
+
refValue = c.value;
|
|
1845
|
+
if (c.kinds.length === 1) refChoice = c.kinds[0].key;
|
|
1846
|
+
else if (!c.kinds.some((k) => k.key === refChoice)) refChoice = null;
|
|
1847
|
+
refError = '';
|
|
1848
|
+
refWarned = false;
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1438
1851
|
function drawRefs() {
|
|
1439
|
-
if (!
|
|
1440
|
-
const field = refConfig.fields.find((f) => f.key === refChoice) ||
|
|
1852
|
+
if (!refOpen) { refsBox.hidden = true; refsBox.innerHTML = ''; return; }
|
|
1853
|
+
const field = refConfig.fields.find((f) => f.key === refChoice) || null;
|
|
1441
1854
|
const optional = refConfig.policy !== 'required';
|
|
1855
|
+
const current = candidateFor(refValue);
|
|
1856
|
+
// Kinds worth offering: the ones the picked value could be, when we know;
|
|
1857
|
+
// every kind otherwise. Never zero — a guess of ours must not lock out the
|
|
1858
|
+
// right answer.
|
|
1859
|
+
const kinds = current && current.kinds.length > 1
|
|
1860
|
+
? refConfig.fields.filter((f) => current.kinds.some((k) => k.key === f.key))
|
|
1861
|
+
: refConfig.fields;
|
|
1862
|
+
// Two loan numbers: the kind is not in question, only which one — so the
|
|
1863
|
+
// kind chips would be a second question nobody needs to answer.
|
|
1864
|
+
const sharedKind = refCandidates.length > 1
|
|
1865
|
+
&& refCandidates.every((c) => c.kinds.length === 1 && c.kinds[0].key === refCandidates[0].kinds[0].key);
|
|
1866
|
+
|
|
1867
|
+
const head = {
|
|
1868
|
+
confirm: refCandidates.length > 1
|
|
1869
|
+
? ['Which one is this about?', 'We found more than one ID in your message. Pick the one this problem is about.']
|
|
1870
|
+
: ['Is this the right ID?', 'This could be more than one kind of ID. Tell us which it is.'],
|
|
1871
|
+
suggest: ['Is this your ID?', 'We spotted this in your message. Tell us what it is so we can find your record.'],
|
|
1872
|
+
ask: optional
|
|
1873
|
+
? ['One more thing', 'So we can find your record — this helps us answer faster.']
|
|
1874
|
+
: ['We need one detail', 'We need one of these to look this up.'],
|
|
1875
|
+
}[refMode];
|
|
1442
1876
|
|
|
1443
1877
|
refsBox.hidden = false;
|
|
1878
|
+
// A handful of kinds read best as chips to tap; a long list goes back to
|
|
1879
|
+
// a select so the card does not turn into a wall of pills.
|
|
1880
|
+
const many = kinds.length > 6;
|
|
1444
1881
|
refsBox.innerHTML = `
|
|
1445
|
-
<
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1882
|
+
<div class="ask-head">
|
|
1883
|
+
<span class="avatar" aria-hidden="true">${ICONS.sparkle}</span>
|
|
1884
|
+
<div>
|
|
1885
|
+
<strong>${escapeHtml(head[0])}</strong>
|
|
1886
|
+
<p class="why">${escapeHtml(head[1])}</p>
|
|
1887
|
+
</div>
|
|
1888
|
+
</div>
|
|
1889
|
+
${refCandidates.length > 1 ? `
|
|
1890
|
+
<div class="chips cands" role="group" aria-label="IDs found in your message">
|
|
1891
|
+
${refCandidates.map((c, i) => `
|
|
1892
|
+
<button type="button" data-ref-cand="${i}" title="${escapeHtml(c.value)}"
|
|
1893
|
+
aria-pressed="${!!current && current === c}">${escapeHtml(shortId(c.value))}</button>
|
|
1894
|
+
`).join('')}
|
|
1895
|
+
</div>` : ''}
|
|
1896
|
+
${kinds.length > 1 && !many && !sharedKind ? `
|
|
1897
|
+
<div class="chips" role="group" aria-label="Which detail you are giving">
|
|
1898
|
+
${kinds.map((f) => `
|
|
1899
|
+
<button type="button" data-ref-kind="${escapeHtml(f.key)}" aria-pressed="${!!field && f.key === field.key}">${escapeHtml(f.label)}</button>
|
|
1900
|
+
`).join('')}
|
|
1901
|
+
</div>` : ''}
|
|
1450
1902
|
<div class="row">
|
|
1451
|
-
${
|
|
1903
|
+
${many ? `
|
|
1452
1904
|
<select class="ref-kind" aria-label="Which detail you are giving">
|
|
1453
|
-
${
|
|
1454
|
-
|
|
1905
|
+
${field ? '' : '<option value="" selected disabled>What is it?</option>'}
|
|
1906
|
+
${kinds.map((f) => `
|
|
1907
|
+
<option value="${escapeHtml(f.key)}" ${field && f.key === field.key ? 'selected' : ''}>${escapeHtml(f.label)}</option>
|
|
1455
1908
|
`).join('')}
|
|
1456
1909
|
</select>` : ''}
|
|
1457
|
-
<input class="ref-value" type="${field
|
|
1458
|
-
inputmode="${escapeHtml(field
|
|
1910
|
+
<input class="ref-value${refError ? ' invalid' : ''}" type="${field?.inputMode === 'email' ? 'email' : 'text'}"
|
|
1911
|
+
inputmode="${escapeHtml(field?.inputMode === 'numeric' || field?.inputMode === 'tel' ? 'numeric' : 'text')}"
|
|
1459
1912
|
value="${escapeHtml(refValue)}"
|
|
1460
|
-
placeholder="${escapeHtml(field.placeholder || '')}"
|
|
1461
|
-
aria-label="${escapeHtml(field.label)}" />
|
|
1913
|
+
placeholder="${escapeHtml(field ? (field.placeholder || field.label) : 'Your ID')}"
|
|
1914
|
+
aria-label="${escapeHtml(field ? field.label : 'Your ID')}" ${refError ? 'aria-invalid="true"' : ''} />
|
|
1462
1915
|
</div>
|
|
1463
|
-
${field
|
|
1464
|
-
${refError ? `<p class="
|
|
1465
|
-
${optional ?
|
|
1466
|
-
|
|
1916
|
+
${field?.hint ? `<p class="hint">${escapeHtml(field.hint)}</p>` : ''}
|
|
1917
|
+
${refError ? `<p class="field-err" role="alert">${ICONS.alert}<span>${escapeHtml(refError)}</span></p>` : ''}
|
|
1918
|
+
${optional ? `<button type="button" class="link" data-act="ref-skip">${
|
|
1919
|
+
refMode === 'ask' ? 'I don\'t have this — send without it' : 'Not sure — send as it is'}</button>` : ''}`;
|
|
1920
|
+
|
|
1921
|
+
refsBox.querySelectorAll('[data-ref-cand]').forEach((chip) => {
|
|
1922
|
+
chip.addEventListener('click', () => {
|
|
1923
|
+
takeCandidate(refCandidates[Number(chip.dataset.refCand)]);
|
|
1924
|
+
drawRefs();
|
|
1925
|
+
});
|
|
1926
|
+
});
|
|
1927
|
+
refsBox.querySelectorAll('[data-ref-kind]').forEach((chip) => {
|
|
1928
|
+
chip.addEventListener('click', () => {
|
|
1929
|
+
refChoice = chip.dataset.refKind;
|
|
1930
|
+
refError = '';
|
|
1931
|
+
refWarned = false;
|
|
1932
|
+
drawRefs();
|
|
1933
|
+
});
|
|
1934
|
+
});
|
|
1467
1935
|
const value = refsBox.querySelector('.ref-value');
|
|
1468
1936
|
value.addEventListener('input', () => {
|
|
1469
1937
|
refValue = value.value;
|
|
@@ -1485,7 +1953,9 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1485
1953
|
refsBox.querySelector('[data-act="ref-skip"]')?.addEventListener('click', () => {
|
|
1486
1954
|
// Skipping is answering. Without this the panel asks again on the next
|
|
1487
1955
|
// press and the reporter is in a loop with a button that does nothing.
|
|
1956
|
+
// The text still goes; the server reads it either way.
|
|
1488
1957
|
refSettled = true;
|
|
1958
|
+
refOpen = false;
|
|
1489
1959
|
refChoice = null;
|
|
1490
1960
|
refValue = '';
|
|
1491
1961
|
refError = '';
|
|
@@ -1494,23 +1964,54 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1494
1964
|
say('');
|
|
1495
1965
|
});
|
|
1496
1966
|
|
|
1497
|
-
|
|
1967
|
+
// Focus where the next decision is: the kind, when that is all that is
|
|
1968
|
+
// missing; the box otherwise.
|
|
1969
|
+
const next = !field && refValue ? refsBox.querySelector('[data-ref-kind], .ref-kind') : null;
|
|
1970
|
+
(next || value).focus();
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
/**
|
|
1974
|
+
* Open the question, pre-filled with the best of what the text offered.
|
|
1975
|
+
*
|
|
1976
|
+
* Conflicts first — the text said two things and only the reporter knows
|
|
1977
|
+
* which — then loose IDs, which are probably the answer and only need a
|
|
1978
|
+
* name. Nothing, and it is the plain question it always was.
|
|
1979
|
+
*/
|
|
1980
|
+
function openRefs(read) {
|
|
1981
|
+
refCandidates = read.conflicts.length ? read.conflicts : read.loose.slice(0, 4);
|
|
1982
|
+
refMode = read.conflicts.length ? 'confirm' : read.loose.length ? 'suggest' : 'ask';
|
|
1983
|
+
refOpen = true;
|
|
1984
|
+
if (refCandidates.length) {
|
|
1985
|
+
// One contested value: pre-fill it so they only choose what it is. Two
|
|
1986
|
+
// or more: pick nothing, because picking is the question.
|
|
1987
|
+
if (refCandidates.length === 1) takeCandidate(refCandidates[0]);
|
|
1988
|
+
else {
|
|
1989
|
+
refValue = '';
|
|
1990
|
+
const [first] = refCandidates;
|
|
1991
|
+
const same = refCandidates.every((c) => c.kinds.length === 1 && c.kinds[0].key === first.kinds[0].key);
|
|
1992
|
+
refChoice = same ? first.kinds[0].key : null;
|
|
1993
|
+
}
|
|
1994
|
+
} else {
|
|
1995
|
+
refChoice = refChoice || refConfig.fields[0].key;
|
|
1996
|
+
}
|
|
1997
|
+
drawRefs();
|
|
1498
1998
|
}
|
|
1499
1999
|
|
|
1500
2000
|
/**
|
|
1501
2001
|
* Whether Send should stop and ask.
|
|
1502
2002
|
*
|
|
1503
|
-
*
|
|
1504
|
-
*
|
|
1505
|
-
*
|
|
1506
|
-
*
|
|
1507
|
-
* question, and being asked again would be the widget not listening.
|
|
2003
|
+
* Never when the text answered it outright. Once when the text said two
|
|
2004
|
+
* things or something unlabelled — on every policy, because sending the
|
|
2005
|
+
* wrong record's id with confidence is worse than one extra tap. And the
|
|
2006
|
+
* plain question on `optional`/`required` projects as before.
|
|
1508
2007
|
*/
|
|
1509
2008
|
function needsReference() {
|
|
1510
2009
|
if (refConfig.policy === 'off' || !refConfig.fields.length) return false;
|
|
1511
|
-
if (referencesInText().length) return false;
|
|
1512
2010
|
if (answeredReference().length) return false;
|
|
1513
|
-
|
|
2011
|
+
const read = readText();
|
|
2012
|
+
if (textAnswers(read)) return false;
|
|
2013
|
+
if (refSettled && refConfig.policy !== 'required') return false;
|
|
2014
|
+
return read;
|
|
1514
2015
|
}
|
|
1515
2016
|
|
|
1516
2017
|
/* ---------- send ---------- */
|
|
@@ -1518,10 +2019,8 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1518
2019
|
sendBtn.addEventListener('click', async () => {
|
|
1519
2020
|
const title = titleInput.value;
|
|
1520
2021
|
if (!title.trim()) {
|
|
1521
|
-
|
|
1522
|
-
titleInput.classList.add('invalid');
|
|
2022
|
+
flagTitle('Add a few words about what went wrong — a short sentence is plenty.');
|
|
1523
2023
|
titleInput.focus();
|
|
1524
|
-
setTimeout(() => titleInput.classList.remove('invalid'), 500);
|
|
1525
2024
|
return;
|
|
1526
2025
|
}
|
|
1527
2026
|
|
|
@@ -1530,9 +2029,18 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1530
2029
|
// costs one extra press on projects that want an identifier, and it is the
|
|
1531
2030
|
// only shape that does not put paperwork in front of a person who came here
|
|
1532
2031
|
// to say something is broken.
|
|
1533
|
-
|
|
1534
|
-
|
|
2032
|
+
//
|
|
2033
|
+
// First, though: a value in the box but no kind picked. Asked rather than
|
|
2034
|
+
// guessed — a wrong kind joins on the wrong table.
|
|
2035
|
+
if (refOpen && refValue.trim() && !refChoice) {
|
|
2036
|
+
refError = 'Pick what this ID is.';
|
|
1535
2037
|
drawRefs();
|
|
2038
|
+
return;
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
const read = needsReference();
|
|
2042
|
+
if (read) {
|
|
2043
|
+
if (!refOpen) openRefs(read); else drawRefs();
|
|
1536
2044
|
say('');
|
|
1537
2045
|
return;
|
|
1538
2046
|
}
|
|
@@ -1559,12 +2067,12 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1559
2067
|
say('');
|
|
1560
2068
|
refreshSendState();
|
|
1561
2069
|
const original = sendBtn.innerHTML;
|
|
1562
|
-
sendBtn.innerHTML = '<span class="spinner"></span>Sending…';
|
|
2070
|
+
sendBtn.innerHTML = '<span class="spinner" aria-hidden="true"></span>Sending…';
|
|
1563
2071
|
|
|
1564
2072
|
try {
|
|
1565
2073
|
const res = await client.report({
|
|
1566
2074
|
title,
|
|
1567
|
-
description:
|
|
2075
|
+
description: descInput.value,
|
|
1568
2076
|
priority,
|
|
1569
2077
|
references: answeredReference(),
|
|
1570
2078
|
attachments: items
|
|
@@ -1811,7 +2319,7 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1811
2319
|
<input type="email" data-fld="email" value="${escapeHtml(pendingEmail)}"
|
|
1812
2320
|
placeholder="you@example.com" autocomplete="email" spellcheck="false"
|
|
1813
2321
|
aria-label="Your email address" ${signInBusy ? 'disabled' : ''} />
|
|
1814
|
-
<button type="button" class="
|
|
2322
|
+
<button type="button" class="primary" data-act="code" ${signInBusy ? 'disabled' : ''}>
|
|
1815
2323
|
${signInBusy ? 'Sending…' : 'Send code'}
|
|
1816
2324
|
</button>
|
|
1817
2325
|
</div>
|
|
@@ -1828,7 +2336,7 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1828
2336
|
<input type="text" class="code" data-fld="code" value="${escapeHtml(pendingCode)}"
|
|
1829
2337
|
placeholder="123456" inputmode="numeric" autocomplete="one-time-code"
|
|
1830
2338
|
maxlength="8" aria-label="The code we emailed you" ${signInBusy ? 'disabled' : ''} />
|
|
1831
|
-
<button type="button" class="
|
|
2339
|
+
<button type="button" class="primary" data-act="confirm" ${signInBusy ? 'disabled' : ''}>
|
|
1832
2340
|
${signInBusy ? 'Checking…' : 'Sign in'}
|
|
1833
2341
|
</button>
|
|
1834
2342
|
</div>
|
|
@@ -1962,7 +2470,7 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
1962
2470
|
} catch (err) {
|
|
1963
2471
|
sending = false;
|
|
1964
2472
|
callBtn.disabled = false;
|
|
1965
|
-
callBtn.
|
|
2473
|
+
callBtn.innerHTML = `${ICONS.phone}Talk to the team`;
|
|
1966
2474
|
refreshSendState();
|
|
1967
2475
|
// A report filed with no call is still a report. Saying "the call
|
|
1968
2476
|
// failed" and nothing else would leave the reporter thinking their
|
|
@@ -2112,16 +2620,12 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
2112
2620
|
*/
|
|
2113
2621
|
async function showHistory() {
|
|
2114
2622
|
const panel = scrim.querySelector('.panel');
|
|
2623
|
+
clearTimeout(assistantTimer);
|
|
2624
|
+
openReportId = null;
|
|
2625
|
+
panel.classList.remove('is-chat');
|
|
2115
2626
|
panel.innerHTML = `
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
<div class="titles">
|
|
2119
|
-
<h2>Your reports</h2>
|
|
2120
|
-
<p>Everything you've reported, and where each one got to.</p>
|
|
2121
|
-
</div>
|
|
2122
|
-
<button class="icon-btn" type="button" data-act="close" aria-label="Close">${ICONS.close}</button>
|
|
2123
|
-
</div>
|
|
2124
|
-
<div class="history"><div class="empty">Loading…</div></div>`;
|
|
2627
|
+
${headHtml({ title: 'Your reports', sub: "Everything you've reported, and where each one got to." })}
|
|
2628
|
+
<div class="history" aria-busy="true">${skeletonRows(3)}</div>`;
|
|
2125
2629
|
|
|
2126
2630
|
panel.querySelector('[data-act="close"]').addEventListener('click', close);
|
|
2127
2631
|
|
|
@@ -2129,8 +2633,8 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
2129
2633
|
try {
|
|
2130
2634
|
({ reports } = await client.myReports());
|
|
2131
2635
|
} catch {
|
|
2132
|
-
panel.querySelector('.history')
|
|
2133
|
-
|
|
2636
|
+
const failed = panel.querySelector('.history');
|
|
2637
|
+
if (failed) failed.innerHTML = emptyHtml('Couldn\'t load your reports just now.', ICONS.alert);
|
|
2134
2638
|
return;
|
|
2135
2639
|
}
|
|
2136
2640
|
|
|
@@ -2138,16 +2642,19 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
2138
2642
|
const list = panel.querySelector('.history');
|
|
2139
2643
|
if (!list) return;
|
|
2140
2644
|
|
|
2645
|
+
list.removeAttribute('aria-busy');
|
|
2141
2646
|
list.innerHTML = reports.length
|
|
2142
|
-
? reports.map((r) => `
|
|
2143
|
-
<button class="row" type="button" data-report="${escapeHtml(r.id)}">
|
|
2647
|
+
? reports.map((r, i) => `
|
|
2648
|
+
<button class="row" type="button" data-report="${escapeHtml(r.id)}" style="animation-delay:${Math.min(i, 8) * 30}ms">
|
|
2649
|
+
<span class="row-ico" aria-hidden="true">${ICONS.chat}</span>
|
|
2144
2650
|
<span class="who">
|
|
2145
2651
|
<span class="ttl">${escapeHtml(r.title)}</span>
|
|
2146
2652
|
<span class="sub">${escapeHtml(r.reference)} · ${escapeHtml(relativeDay(r.createdAt))}</span>
|
|
2147
2653
|
</span>
|
|
2148
2654
|
<span class="pill ${escapeHtml(String(r.status).toLowerCase())}">${escapeHtml(statusWord(r.status))}</span>
|
|
2655
|
+
<span class="chev" aria-hidden="true">${ICONS.chevronRight}</span>
|
|
2149
2656
|
</button>`).join('')
|
|
2150
|
-
: '
|
|
2657
|
+
: emptyHtml('You haven\'t reported anything yet.', ICONS.inbox);
|
|
2151
2658
|
|
|
2152
2659
|
for (const row of list.querySelectorAll('[data-report]')) {
|
|
2153
2660
|
row.addEventListener('click', () => showReport(row.dataset.report));
|
|
@@ -2169,16 +2676,15 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
2169
2676
|
*/
|
|
2170
2677
|
async function showReport(bugId, { fresh = false } = {}) {
|
|
2171
2678
|
const panel = scrim.querySelector('.panel');
|
|
2679
|
+
clearTimeout(assistantTimer);
|
|
2680
|
+
panel.classList.add('is-chat');
|
|
2172
2681
|
panel.innerHTML = `
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
<button class="icon-btn" type="button" data-act="close" aria-label="Close">${ICONS.close}</button>
|
|
2180
|
-
</div>
|
|
2181
|
-
<div class="history"><div class="empty">Loading…</div></div>`;
|
|
2682
|
+
${headHtml({
|
|
2683
|
+
back: true,
|
|
2684
|
+
title: fresh ? 'Looking into it' : 'Your report',
|
|
2685
|
+
sub: fresh ? 'Our assistant is on it — it may ask you a question here.' : 'Everything that has happened, and a way to answer.',
|
|
2686
|
+
})}
|
|
2687
|
+
<div class="history chatting" aria-busy="true"><div class="thread">${skeletonBubbles()}</div></div>`;
|
|
2182
2688
|
|
|
2183
2689
|
panel.querySelector('[data-act="close"]').addEventListener('click', close);
|
|
2184
2690
|
panel.querySelector('[data-act="back"]').addEventListener('click', showHistory);
|
|
@@ -2195,8 +2701,9 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
2195
2701
|
if (!body || !panel.querySelector('[data-act="back"]')) return false;
|
|
2196
2702
|
|
|
2197
2703
|
if (!report) {
|
|
2198
|
-
if (fresh) return false;
|
|
2199
|
-
body.
|
|
2704
|
+
if (fresh) { panel.classList.remove('is-chat'); return false; }
|
|
2705
|
+
body.removeAttribute('aria-busy');
|
|
2706
|
+
body.innerHTML = emptyHtml('Couldn\'t load that report just now.', ICONS.alert);
|
|
2200
2707
|
return false;
|
|
2201
2708
|
}
|
|
2202
2709
|
|
|
@@ -2205,7 +2712,9 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
2205
2712
|
// fixed" card up over the screen where they are reading that it was fixed
|
|
2206
2713
|
// would be the notification arguing with itself.
|
|
2207
2714
|
client.markReportRead(bugId);
|
|
2715
|
+
const drawn = threadHtml(report.timeline);
|
|
2208
2716
|
body.classList.add('chatting');
|
|
2717
|
+
body.removeAttribute('aria-busy');
|
|
2209
2718
|
body.innerHTML = `
|
|
2210
2719
|
<div class="report-head">
|
|
2211
2720
|
<span class="ttl">${escapeHtml(report.title)}</span>
|
|
@@ -2220,13 +2729,17 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
2220
2729
|
? `<span class="eta-line">Usually takes ${escapeHtml(String(report.estimate.label))}</span>`
|
|
2221
2730
|
: ''}
|
|
2222
2731
|
</div>
|
|
2223
|
-
<div class="thread">${
|
|
2732
|
+
<div class="thread" role="log" aria-live="polite" aria-label="Conversation">${drawn}</div>`;
|
|
2224
2733
|
|
|
2225
2734
|
const thread = body.querySelector('.thread');
|
|
2735
|
+
threadDrawn.set(thread, drawn);
|
|
2226
2736
|
scrollToLatest(thread);
|
|
2227
2737
|
mountVerification(body, report);
|
|
2228
2738
|
if (report.canReply) mountComposer(body, thread, bugId);
|
|
2229
2739
|
mountAssistant(body, report, bugId);
|
|
2740
|
+
// Again, now the cards above it have taken their share of the height: the
|
|
2741
|
+
// latest message — and any quick answers under it — is what they came for.
|
|
2742
|
+
scrollToLatest(thread);
|
|
2230
2743
|
return true;
|
|
2231
2744
|
}
|
|
2232
2745
|
|
|
@@ -2240,25 +2753,83 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
2240
2753
|
function mountAssistant(body, report, bugId) {
|
|
2241
2754
|
clearTimeout(assistantTimer);
|
|
2242
2755
|
const a = report.assistant;
|
|
2243
|
-
body.querySelector('.
|
|
2244
|
-
|
|
2245
|
-
|
|
2756
|
+
const thread = body.querySelector('.thread');
|
|
2757
|
+
let card = body.querySelector('.assistant');
|
|
2758
|
+
if (!a) {
|
|
2759
|
+
card?.remove();
|
|
2760
|
+
thread?.querySelector('.typing-row')?.remove();
|
|
2761
|
+
return;
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
// Updated in place rather than rebuilt: the card is polled every few
|
|
2765
|
+
// seconds while the assistant works, and a card that re-entered on every
|
|
2766
|
+
// poll would flicker — and re-announce itself to a screen reader — for
|
|
2767
|
+
// nothing. It only animates when the phase actually moves.
|
|
2768
|
+
const previous = card?.dataset.phase || null;
|
|
2769
|
+
if (!card) {
|
|
2770
|
+
card = document.createElement('div');
|
|
2771
|
+
card.setAttribute('role', 'status');
|
|
2772
|
+
card.setAttribute('aria-live', 'polite');
|
|
2773
|
+
body.querySelector('.report-head')?.after(card);
|
|
2774
|
+
}
|
|
2246
2775
|
card.className = `assistant ${escapeHtml(a.phase)}`;
|
|
2247
|
-
card.
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2776
|
+
card.dataset.phase = a.phase;
|
|
2777
|
+
|
|
2778
|
+
const steps = a.steps || [];
|
|
2779
|
+
const done = steps.filter((st) => st.state === 'done').length;
|
|
2780
|
+
const active = steps.some((st) => st.state === 'active') ? 0.5 : 0;
|
|
2781
|
+
const pct = steps.length ? Math.round(((done + active) / steps.length) * 100) : 0;
|
|
2782
|
+
const content = `
|
|
2783
|
+
<div class="as-body${previous && previous !== a.phase ? ' entering' : ''}">
|
|
2784
|
+
<div class="as-top">
|
|
2785
|
+
<span class="as-orb" aria-hidden="true">${a.phase === 'analyzing' ? '<span class="as-spin"></span>'
|
|
2786
|
+
: a.phase === 'calling' ? ICONS.phone
|
|
2787
|
+
: a.phase === 'resolved' ? ICONS.check : ICONS.sparkle}</span>
|
|
2788
|
+
<div class="as-text">
|
|
2789
|
+
<div class="as-title">${escapeHtml(a.title)}</div>
|
|
2790
|
+
<div class="as-detail">${escapeHtml(a.detail)}</div>
|
|
2791
|
+
</div>
|
|
2255
2792
|
</div>
|
|
2256
2793
|
</div>
|
|
2257
|
-
|
|
2258
|
-
|
|
2794
|
+
${steps.length ? `
|
|
2795
|
+
<div class="as-progress" role="progressbar" aria-label="Progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${pct}"><i style="width:${pct}%"></i></div>
|
|
2796
|
+
<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>` : ''}`;
|
|
2797
|
+
const key = JSON.stringify([a.phase, a.title, a.detail, steps]);
|
|
2798
|
+
if (card.dataset.key !== key) {
|
|
2799
|
+
const bar = card.querySelector('.as-progress i');
|
|
2800
|
+
const from = bar?.style.width;
|
|
2801
|
+
card.innerHTML = content;
|
|
2802
|
+
card.dataset.key = key;
|
|
2803
|
+
// Let the bar travel from where it was to where it is now.
|
|
2804
|
+
const next = card.querySelector('.as-progress i');
|
|
2805
|
+
if (next && from) {
|
|
2806
|
+
next.style.width = from;
|
|
2807
|
+
requestAnimationFrame(() => { next.style.width = `${pct}%`; });
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
// While it works, a "thinking" bubble sits where its next message will land.
|
|
2812
|
+
// Never during a call: somebody is on the phone about this report, nothing
|
|
2813
|
+
// is being analysed (the analysis waits for the transcript), and a bubble
|
|
2814
|
+
// typing away underneath a live call is the assistant talking over them.
|
|
2815
|
+
if (thread) {
|
|
2816
|
+
const pinned = atLatest(thread);
|
|
2817
|
+
const typing = thread.querySelector('.typing-row');
|
|
2818
|
+
if (a.phase !== 'analyzing') typing?.remove();
|
|
2819
|
+
else if (!typing || typing !== thread.lastElementChild) {
|
|
2820
|
+
typing?.remove();
|
|
2821
|
+
thread.insertAdjacentHTML('beforeend', typingHtml());
|
|
2822
|
+
if (pinned) scrollToLatest(thread);
|
|
2823
|
+
}
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2259
2826
|
if (a.phase === 'analyzing') {
|
|
2260
2827
|
assistantTimer = setTimeout(() => refreshThread(bugId), 3000);
|
|
2261
|
-
} else if (a.phase === '
|
|
2828
|
+
} else if (a.phase === 'calling') {
|
|
2829
|
+
// Slower: the call's own card is what the reporter is watching, and this
|
|
2830
|
+
// only has to notice when the call ends and the analysis begins.
|
|
2831
|
+
assistantTimer = setTimeout(() => refreshThread(bugId), 10000);
|
|
2832
|
+
} else if (a.phase === 'needs_info' && previous !== 'needs_info') {
|
|
2262
2833
|
body.querySelector('.composer textarea')?.focus();
|
|
2263
2834
|
}
|
|
2264
2835
|
}
|
|
@@ -2298,9 +2869,15 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
2298
2869
|
if (!report || openReportId !== bugId) return;
|
|
2299
2870
|
|
|
2300
2871
|
mountVerification(body, report);
|
|
2301
|
-
mountAssistant(body, report, bugId);
|
|
2302
2872
|
const pinned = atLatest(thread);
|
|
2303
|
-
|
|
2873
|
+
const html = threadHtml(report.timeline);
|
|
2874
|
+
// Only rewritten when something changed, so a poll that brings nothing new
|
|
2875
|
+
// does not replay every bubble's entrance or re-announce the log.
|
|
2876
|
+
if (threadDrawn.get(thread) !== html) {
|
|
2877
|
+
thread.innerHTML = html;
|
|
2878
|
+
threadDrawn.set(thread, html);
|
|
2879
|
+
}
|
|
2880
|
+
mountAssistant(body, report, bugId);
|
|
2304
2881
|
// Only follow the conversation down if they were already at the bottom.
|
|
2305
2882
|
// Yanking someone back from something they scrolled up to read is worse
|
|
2306
2883
|
// than making them scroll.
|
|
@@ -2328,8 +2905,9 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
2328
2905
|
<div class="staged" hidden></div>
|
|
2329
2906
|
<div class="row">
|
|
2330
2907
|
<div class="tools">
|
|
2908
|
+
${cfg.attachments === false ? '' : `
|
|
2331
2909
|
<button class="tool" type="button" data-act="attach" title="Attach a file" aria-label="Attach a file">${ICONS.paperclip}</button>
|
|
2332
|
-
<button class="tool" type="button" data-act="voice" title="Record a voice note" aria-label="Record a voice note">${ICONS.mic}</button
|
|
2910
|
+
<button class="tool" type="button" data-act="voice" title="Record a voice note" aria-label="Record a voice note">${ICONS.mic}</button>`}
|
|
2333
2911
|
<button class="tool" type="button" data-act="livecall" title="Call the team" aria-label="Call the team" hidden>${ICONS.phone}</button>
|
|
2334
2912
|
</div>
|
|
2335
2913
|
<textarea rows="1" placeholder="Write a reply…" aria-label="Write a reply"></textarea>
|
|
@@ -2409,7 +2987,11 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
2409
2987
|
}
|
|
2410
2988
|
});
|
|
2411
2989
|
|
|
2412
|
-
|
|
2990
|
+
// Absent when the host set `attachments: false` — which the report form
|
|
2991
|
+
// has always honoured and this composer did not, so a host keeping files
|
|
2992
|
+
// and microphone audio outside its compliance perimeter still had both
|
|
2993
|
+
// offered in the follow-up thread.
|
|
2994
|
+
composer.querySelector('[data-act="attach"]')?.addEventListener('click', () => picker.click());
|
|
2413
2995
|
picker.addEventListener('change', () => {
|
|
2414
2996
|
say('');
|
|
2415
2997
|
for (const file of picker.files) {
|
|
@@ -2422,12 +3004,12 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
2422
3004
|
picker.value = '';
|
|
2423
3005
|
});
|
|
2424
3006
|
|
|
2425
|
-
if (!voiceSupported()) {
|
|
3007
|
+
if (voiceBtn && !voiceSupported()) {
|
|
2426
3008
|
voiceBtn.disabled = true;
|
|
2427
3009
|
voiceBtn.title = 'This browser cannot record audio';
|
|
2428
3010
|
}
|
|
2429
3011
|
|
|
2430
|
-
voiceBtn
|
|
3012
|
+
voiceBtn?.addEventListener('click', async () => {
|
|
2431
3013
|
if (recorder) {
|
|
2432
3014
|
const handle = recorder;
|
|
2433
3015
|
recorder = null;
|
|
@@ -2503,7 +3085,7 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
2503
3085
|
at: new Date().toISOString(),
|
|
2504
3086
|
attachments: ready.map((f) => ({ type: 'FILE', name: f.name, size: f.size })),
|
|
2505
3087
|
}, 'pending'));
|
|
2506
|
-
scrollToLatest(thread);
|
|
3088
|
+
scrollToLatest(thread, { smooth: true });
|
|
2507
3089
|
|
|
2508
3090
|
try {
|
|
2509
3091
|
await sendMessage({
|
|
@@ -2546,6 +3128,7 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
2546
3128
|
<span class="call-dot" aria-hidden="true"></span>
|
|
2547
3129
|
<span class="who" role="status" aria-live="polite">Calling the team…</span>
|
|
2548
3130
|
<span class="secs" hidden></span>
|
|
3131
|
+
${handle?.recorded ? '<span class="call-kept">This call is recorded.</span>' : ''}
|
|
2549
3132
|
<button class="act" type="button" data-act="mute" hidden>Mute</button>
|
|
2550
3133
|
<button class="act danger" type="button" data-act="hangup">Cancel</button>`;
|
|
2551
3134
|
body.insertBefore(bar, thread);
|
|
@@ -2653,14 +3236,38 @@ function openPanel({ root, host, dock, client, onClose }) {
|
|
|
2653
3236
|
function threadHtml(timeline) {
|
|
2654
3237
|
const entries = timeline || [];
|
|
2655
3238
|
if (!entries.length) return '';
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
3239
|
+
let day = null;
|
|
3240
|
+
let prev = null;
|
|
3241
|
+
return entries.map((e) => {
|
|
3242
|
+
// A rule with the date on it whenever the calendar day turns, the way a
|
|
3243
|
+
// chat marks that the next message came later.
|
|
3244
|
+
const d = dayKey(e.at);
|
|
3245
|
+
const sep = d && d !== day ? `<div class="day-sep" role="separator">${escapeHtml(dayLabel(e.at))}</div>` : '';
|
|
3246
|
+
if (sep) { day = d; prev = null; }
|
|
3247
|
+
if (!e.from) { prev = null; return sep + systemHtml(e); }
|
|
3248
|
+
const message = {
|
|
3249
|
+
from: e.from,
|
|
3250
|
+
author: e.reply?.author,
|
|
3251
|
+
automated: e.reply?.automated,
|
|
3252
|
+
body: e.reply?.body,
|
|
3253
|
+
at: e.at,
|
|
3254
|
+
attachments: e.reply?.attachments,
|
|
3255
|
+
};
|
|
3256
|
+
// Consecutive messages from the same voice within a few minutes are one
|
|
3257
|
+
// turn: one avatar, one name, the bubbles stacked under it.
|
|
3258
|
+
const who = speakerOf(message);
|
|
3259
|
+
const cont = prev && prev.who === who
|
|
3260
|
+
&& Math.abs(new Date(e.at).getTime() - new Date(prev.at).getTime()) < 5 * 60_000;
|
|
3261
|
+
prev = { who, at: e.at };
|
|
3262
|
+
return sep + messageHtml(message, cont ? 'cont' : '');
|
|
3263
|
+
}).join('');
|
|
3264
|
+
}
|
|
3265
|
+
|
|
3266
|
+
/** Which voice a message is in, for grouping: the reporter, the assistant, or a named person. */
|
|
3267
|
+
function speakerOf(message) {
|
|
3268
|
+
if (message.from === 'reporter') return 'me';
|
|
3269
|
+
if (message.automated) return 'assistant';
|
|
3270
|
+
return `team:${message.author || ''}`;
|
|
2664
3271
|
}
|
|
2665
3272
|
|
|
2666
3273
|
/**
|
|
@@ -2671,15 +3278,17 @@ function threadHtml(timeline) {
|
|
|
2671
3278
|
* portal exactly rather than being a third attempt at the same sentence.
|
|
2672
3279
|
*/
|
|
2673
3280
|
function systemHtml(entry) {
|
|
3281
|
+
const when = clockTime(entry.at);
|
|
2674
3282
|
return `
|
|
2675
3283
|
<div class="sys k-${escapeHtml(entry.kind)}">
|
|
2676
|
-
<span class="dot" aria-hidden="true"></span>${escapeHtml(entry.summary || '')}
|
|
2677
|
-
|
|
3284
|
+
<span class="dot" aria-hidden="true"></span>${escapeHtml(entry.summary || '')}${when ? `
|
|
3285
|
+
<span class="t">· ${escapeHtml(when)}</span>` : ''}
|
|
2678
3286
|
</div>`;
|
|
2679
3287
|
}
|
|
2680
3288
|
|
|
2681
3289
|
/**
|
|
2682
|
-
* One bubble. `state` marks a message that is still in flight, or failed
|
|
3290
|
+
* One bubble. `state` marks a message that is still in flight, or failed, or
|
|
3291
|
+
* one that continues the turn above it.
|
|
2683
3292
|
*
|
|
2684
3293
|
* A reply that was sent automatically says so, above the words rather than
|
|
2685
3294
|
* after them. The person reading it is deciding whether their problem has been
|
|
@@ -2703,30 +3312,133 @@ function messageHtml(message, state = '') {
|
|
|
2703
3312
|
|
|
2704
3313
|
// Named, or labelled, never both and never neither: an automated message has
|
|
2705
3314
|
// no author to name, and naming one would be the lie the label exists to
|
|
2706
|
-
// prevent.
|
|
3315
|
+
// prevent. The avatar says the same thing at a glance.
|
|
2707
3316
|
let who = '';
|
|
2708
|
-
|
|
2709
|
-
|
|
3317
|
+
let avatar = '';
|
|
3318
|
+
if (automated) {
|
|
3319
|
+
who = '<span class="msg-who auto">Assistant <span class="tag">AI</span></span>';
|
|
3320
|
+
avatar = `<span class="av bot" aria-hidden="true">${ICONS.sparkle}</span>`;
|
|
3321
|
+
} else if (!mine) {
|
|
3322
|
+
const name = message.author || 'Support team';
|
|
3323
|
+
who = `<span class="msg-who">${escapeHtml(name)}</span>`;
|
|
3324
|
+
avatar = `<span class="av" aria-hidden="true">${escapeHtml(initials(name))}</span>`;
|
|
3325
|
+
}
|
|
3326
|
+
if (state.includes('cont')) who = '';
|
|
2710
3327
|
|
|
2711
|
-
//
|
|
2712
|
-
//
|
|
2713
|
-
//
|
|
2714
|
-
|
|
3328
|
+
// The assistant's one answer ends by inviting a correction (check-rules
|
|
3329
|
+
// confirmMessage). One tap sends it, and what comes back goes to the person
|
|
3330
|
+
// who now owns the report rather than to another automated round — so the
|
|
3331
|
+
// wording is "that's right" and "that's not it", not a yes/no question.
|
|
3332
|
+
const confirm = automated && /it goes to the same person/i.test(body)
|
|
2715
3333
|
? `<div class="quick" role="group" aria-label="Answer">
|
|
2716
|
-
<button type="button" data-quick="Yes, this is the problem"
|
|
2717
|
-
<button type="button" data-quick="No, that is not the problem">
|
|
3334
|
+
<button type="button" data-quick="Yes, this is the problem">${ICONS.check}That's right</button>
|
|
3335
|
+
<button type="button" data-quick="No, that is not the problem">That's not it</button>
|
|
2718
3336
|
</div>`
|
|
2719
3337
|
: '';
|
|
2720
3338
|
|
|
2721
3339
|
return `
|
|
2722
3340
|
<div class="msg-row ${mine ? 'mine' : 'theirs'} ${automated ? 'auto' : ''} ${state}">
|
|
3341
|
+
${avatar}
|
|
2723
3342
|
${who}
|
|
2724
3343
|
<div class="bubble ${body ? '' : 'silent'}">${text}${files}</div>
|
|
2725
3344
|
${confirm}
|
|
2726
|
-
<span class="msg-when">${escapeHtml(relativeDay(message.at))}</span>
|
|
3345
|
+
<span class="msg-when">${escapeHtml(clockTime(message.at) || relativeDay(message.at))}</span>
|
|
2727
3346
|
</div>`;
|
|
2728
3347
|
}
|
|
2729
3348
|
|
|
3349
|
+
/** The assistant, thinking: where its next message will appear. */
|
|
3350
|
+
function typingHtml() {
|
|
3351
|
+
return `
|
|
3352
|
+
<div class="typing-row">
|
|
3353
|
+
<span class="av bot" aria-hidden="true">${ICONS.sparkle}</span>
|
|
3354
|
+
<span class="msg-who auto">Assistant <span class="tag">AI</span></span>
|
|
3355
|
+
<div class="bubble typing" role="img" aria-label="The assistant is working on it"><i></i><i></i><i></i></div>
|
|
3356
|
+
</div>`;
|
|
3357
|
+
}
|
|
3358
|
+
|
|
3359
|
+
/**
|
|
3360
|
+
* The panel's header, on every screen. The assistant's avatar carries the
|
|
3361
|
+
* identity; `back` adds the way out of a report to the list of them.
|
|
3362
|
+
*/
|
|
3363
|
+
function headHtml({ title, sub, id = '', back = false }) {
|
|
3364
|
+
return `
|
|
3365
|
+
<div class="head">
|
|
3366
|
+
${back ? `<button class="icon-btn back" type="button" data-act="back" aria-label="Back to your reports">${ICONS.back}</button>` : ''}
|
|
3367
|
+
<div class="mark" aria-hidden="true">${ICONS.sparkle}</div>
|
|
3368
|
+
<div class="titles">
|
|
3369
|
+
<h2${id ? ` id="${id}"` : ''}>${escapeHtml(title)}</h2>
|
|
3370
|
+
<p>${escapeHtml(sub)}</p>
|
|
3371
|
+
</div>
|
|
3372
|
+
<button class="icon-btn" type="button" data-act="close" aria-label="Close">${ICONS.close}</button>
|
|
3373
|
+
</div>`;
|
|
3374
|
+
}
|
|
3375
|
+
|
|
3376
|
+
function emptyHtml(text, icon) {
|
|
3377
|
+
return `<div class="empty"><span class="empty-ico" aria-hidden="true">${icon}</span>${escapeHtml(text)}</div>`;
|
|
3378
|
+
}
|
|
3379
|
+
|
|
3380
|
+
/** Placeholder rows while the list loads — the shape of what is coming. */
|
|
3381
|
+
function skeletonRows(n) {
|
|
3382
|
+
return Array.from({ length: n }, () => `
|
|
3383
|
+
<div class="row" aria-hidden="true">
|
|
3384
|
+
<span class="row-ico"></span>
|
|
3385
|
+
<span class="who"><span class="skel" style="width:70%"></span><span class="skel" style="width:40%;height:9px"></span></span>
|
|
3386
|
+
</div>`).join('') + '<span class="sr-only">Loading…</span>';
|
|
3387
|
+
}
|
|
3388
|
+
|
|
3389
|
+
function skeletonBubbles() {
|
|
3390
|
+
return `
|
|
3391
|
+
<div aria-hidden="true" style="display:flex;flex-direction:column;gap:10px;padding-top:6px">
|
|
3392
|
+
<span class="skel" style="width:42%;height:34px;border-radius:16px;align-self:center"></span>
|
|
3393
|
+
<span class="skel" style="width:66%;height:52px;border-radius:18px"></span>
|
|
3394
|
+
<span class="skel" style="width:48%;height:38px;border-radius:18px;align-self:flex-end"></span>
|
|
3395
|
+
</div>
|
|
3396
|
+
<span class="sr-only">Loading…</span>`;
|
|
3397
|
+
}
|
|
3398
|
+
|
|
3399
|
+
// What each open thread was last drawn from, so a poll that brings nothing new
|
|
3400
|
+
// leaves the DOM (and the reader's place in it) alone.
|
|
3401
|
+
const threadDrawn = new WeakMap();
|
|
3402
|
+
|
|
3403
|
+
function initials(name) {
|
|
3404
|
+
const parts = String(name).trim().split(/\s+/).filter(Boolean);
|
|
3405
|
+
if (!parts.length) return '?';
|
|
3406
|
+
return (parts[0][0] + (parts.length > 1 ? parts[parts.length - 1][0] : '')).toUpperCase();
|
|
3407
|
+
}
|
|
3408
|
+
|
|
3409
|
+
function dayKey(iso) {
|
|
3410
|
+
const d = new Date(iso);
|
|
3411
|
+
return Number.isFinite(d.getTime()) ? `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}` : null;
|
|
3412
|
+
}
|
|
3413
|
+
|
|
3414
|
+
/** "Today" / "Yesterday" / "Mon, 14 Sep" — the label on a day separator. */
|
|
3415
|
+
function dayLabel(iso) {
|
|
3416
|
+
const d = new Date(iso);
|
|
3417
|
+
const today = new Date();
|
|
3418
|
+
const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1);
|
|
3419
|
+
if (dayKey(d) === dayKey(today)) return 'Today';
|
|
3420
|
+
if (dayKey(d) === dayKey(yesterday)) return 'Yesterday';
|
|
3421
|
+
try {
|
|
3422
|
+
return d.toLocaleDateString(undefined, {
|
|
3423
|
+
weekday: 'short', day: 'numeric', month: 'short',
|
|
3424
|
+
...(d.getFullYear() !== today.getFullYear() ? { year: 'numeric' } : {}),
|
|
3425
|
+
});
|
|
3426
|
+
} catch {
|
|
3427
|
+
return relativeDay(iso);
|
|
3428
|
+
}
|
|
3429
|
+
}
|
|
3430
|
+
|
|
3431
|
+
/** "14:05" or "2:05 PM", in the reader's own convention. */
|
|
3432
|
+
function clockTime(iso) {
|
|
3433
|
+
const d = new Date(iso);
|
|
3434
|
+
if (!Number.isFinite(d.getTime())) return '';
|
|
3435
|
+
try {
|
|
3436
|
+
return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
|
3437
|
+
} catch {
|
|
3438
|
+
return '';
|
|
3439
|
+
}
|
|
3440
|
+
}
|
|
3441
|
+
|
|
2730
3442
|
/**
|
|
2731
3443
|
* One attachment, rendered as what it is.
|
|
2732
3444
|
*
|
|
@@ -2761,8 +3473,10 @@ function atLatest(thread) {
|
|
|
2761
3473
|
return thread.scrollHeight - thread.scrollTop - thread.clientHeight < 40;
|
|
2762
3474
|
}
|
|
2763
3475
|
|
|
2764
|
-
function scrollToLatest(thread) {
|
|
2765
|
-
|
|
3476
|
+
function scrollToLatest(thread, { smooth = false } = {}) {
|
|
3477
|
+
const calm = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
3478
|
+
if (smooth && !calm && thread.scrollTo) thread.scrollTo({ top: thread.scrollHeight, behavior: 'smooth' });
|
|
3479
|
+
else thread.scrollTop = thread.scrollHeight;
|
|
2766
3480
|
}
|
|
2767
3481
|
|
|
2768
3482
|
/** Status as a word a reporter would use, not the enum the database stores. */
|