@guidelytech/guide-runtime 0.8.0 → 0.10.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/dist/messenger.js CHANGED
@@ -1,12 +1,13 @@
1
1
  import {
2
2
  createHelpView,
3
3
  el,
4
- listItem
5
- } from "./chunk-4FKYF6OB.js";
6
- import "./chunk-7DOGZKWD.js";
4
+ resultRow,
5
+ startableGuide
6
+ } from "./chunk-KJRXCITD.js";
7
+ import "./chunk-PG7CMBHD.js";
7
8
 
8
9
  // src/messenger.ts
9
- import { foldPathIds, pageLabel } from "@guidelytech/shared/runtime";
10
+ import { foldPathIds, matchesHelpQuery, pageLabel } from "@guidelytech/shared/runtime";
10
11
 
11
12
  // src/howto-view.ts
12
13
  var pct = (v) => `${(v * 100).toFixed(3)}%`;
@@ -67,6 +68,15 @@ function renderHowTo(doc, howTo, onShowMe) {
67
68
  show.addEventListener("click", () => onShowMe(guideKey));
68
69
  wrap.appendChild(show);
69
70
  }
71
+ if (howTo.video && /^https?:\/\//i.test(howTo.video.url)) {
72
+ const video = el(doc, "video", "g-howto-video");
73
+ video.src = howTo.video.url;
74
+ video.controls = true;
75
+ video.preload = "metadata";
76
+ video.playsInline = true;
77
+ video.setAttribute("aria-label", `Video: ${howTo.title}`);
78
+ wrap.appendChild(video);
79
+ }
70
80
  const list = el(doc, "ol", "g-howto-steps");
71
81
  let n = 0;
72
82
  for (const s of howTo.steps) {
@@ -100,6 +110,275 @@ function renderHowTo(doc, howTo, onShowMe) {
100
110
  return wrap;
101
111
  }
102
112
 
113
+ // src/complaint-view.ts
114
+ import { MAX_COMPLAINT_ANSWER_LENGTH, MAX_COMPLAINT_ATTACHMENT_BYTES, MAX_COMPLAINT_LENGTH } from "@guidelytech/shared/runtime";
115
+ var emptyComplaintDraft = (description = "") => ({ description, answers: {}, email: "", attachment: null, error: null, sending: false, checked: false });
116
+ var COMPLAINT_STEPS = [
117
+ ["open", "Received"],
118
+ ["in_review", "In review"],
119
+ ["resolved", "Resolved"]
120
+ ];
121
+ var complaintStatusLabel = (s) => COMPLAINT_STEPS.find(([k]) => k === s)?.[1] ?? s;
122
+ var EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
123
+ function complaintProblem(category, draft, needsEmail) {
124
+ for (const f of category.fields) if (f.required && !(draft.answers[f.id] ?? "").trim()) return `${f.label} is required.`;
125
+ if (!draft.description.trim()) return "Tell us what happened.";
126
+ if (needsEmail && !EMAIL.test(draft.email.trim())) return "Enter your email address so the team can reply.";
127
+ return null;
128
+ }
129
+ function complaintAnswers(category, draft) {
130
+ const out = {};
131
+ for (const f of category.fields) {
132
+ const v = (draft.answers[f.id] ?? "").trim();
133
+ if (v) out[f.id] = v.slice(0, MAX_COMPLAINT_ANSWER_LENGTH);
134
+ }
135
+ return out;
136
+ }
137
+ function statusPill(doc, status) {
138
+ return el(doc, "span", `g-m-pill g-m-pill-${status}`, complaintStatusLabel(status));
139
+ }
140
+ function dot(doc, color) {
141
+ const d = el(doc, "span", "g-m-cat-dot");
142
+ if (/^#[0-9a-fA-F]{6}$/.test(color)) d.style.background = color;
143
+ d.setAttribute("aria-hidden", "true");
144
+ return d;
145
+ }
146
+ function categoryList(doc, categories, onPick) {
147
+ const wrap = el(doc, "div", "g-m-form");
148
+ const intro = el(doc, "div");
149
+ intro.append(el(doc, "h3", "g-m-form-title", "What's it about?"), el(doc, "p", "g-m-card-sub", "Picking the right one gets it to the right team faster."));
150
+ wrap.appendChild(intro);
151
+ const list = el(doc, "div", "g-m-cats");
152
+ list.setAttribute("role", "list");
153
+ for (const c of categories) {
154
+ const b = el(doc, "button", "g-m-card g-m-row g-m-center g-m-cat");
155
+ b.type = "button";
156
+ b.setAttribute("role", "listitem");
157
+ b.appendChild(dot(doc, c.color));
158
+ const text = el(doc, "span", "g-m-grow");
159
+ text.appendChild(el(doc, "span", "g-m-card-title", c.name));
160
+ if (c.description) text.appendChild(el(doc, "span", "g-m-card-sub", c.description));
161
+ b.appendChild(text);
162
+ b.addEventListener("click", () => onPick(c));
163
+ list.appendChild(b);
164
+ }
165
+ wrap.appendChild(list);
166
+ return wrap;
167
+ }
168
+ var fieldSeq = 0;
169
+ function field(doc, label, control, opts = {}) {
170
+ const id = `g-m-f${++fieldSeq}`;
171
+ control.id = id;
172
+ const wrap = el(doc, "div", "g-m-field");
173
+ const l = el(doc, "label", "g-m-label", label);
174
+ l.htmlFor = id;
175
+ if (opts.optional) l.appendChild(el(doc, "span", "g-m-optional", " (optional)"));
176
+ wrap.append(l, control);
177
+ if (opts.hint) {
178
+ const hint = el(doc, "span", "g-m-hint", opts.hint);
179
+ hint.id = `${id}-hint`;
180
+ control.setAttribute("aria-describedby", hint.id);
181
+ wrap.appendChild(hint);
182
+ }
183
+ if (opts.invalid) control.setAttribute("aria-invalid", "true");
184
+ return wrap;
185
+ }
186
+ function complaintForm(doc, o) {
187
+ const { category, draft } = o;
188
+ const form = el(doc, "form", "g-m-form");
189
+ form.noValidate = true;
190
+ const picked = el(doc, "div", "g-m-picked g-m-row g-m-center");
191
+ picked.append(dot(doc, category.color), el(doc, "span", "g-m-grow g-m-card-title", category.name));
192
+ if (o.canChangeCategory) {
193
+ const change = el(doc, "button", "g-m-text-btn", "Change");
194
+ change.type = "button";
195
+ change.setAttribute("aria-label", `Change category (${category.name})`);
196
+ change.addEventListener("click", o.onChangeCategory);
197
+ picked.appendChild(change);
198
+ }
199
+ form.appendChild(picked);
200
+ for (const f of category.fields) {
201
+ const missing = draft.checked && f.required && !(draft.answers[f.id] ?? "").trim();
202
+ let control;
203
+ if (f.type === "choice") {
204
+ const select = el(doc, "select", "g-input");
205
+ const option = (label, value) => {
206
+ const o2 = el(doc, "option", "", label);
207
+ o2.value = value;
208
+ return o2;
209
+ };
210
+ select.appendChild(option(f.required ? "Choose one" : "Choose one (optional)", ""));
211
+ for (const opt of f.options ?? []) select.appendChild(option(opt, opt));
212
+ select.value = draft.answers[f.id] ?? "";
213
+ select.addEventListener("change", () => draft.answers[f.id] = select.value);
214
+ control = select;
215
+ } else {
216
+ const input = el(doc, "input", "g-input");
217
+ input.type = "text";
218
+ input.maxLength = MAX_COMPLAINT_ANSWER_LENGTH;
219
+ input.value = draft.answers[f.id] ?? "";
220
+ input.addEventListener("input", () => draft.answers[f.id] = input.value);
221
+ control = input;
222
+ }
223
+ if (f.required) control.required = true;
224
+ form.appendChild(field(doc, f.label, control, { optional: !f.required, invalid: missing }));
225
+ }
226
+ const what = el(doc, "textarea", "g-input g-m-textarea");
227
+ what.rows = 5;
228
+ what.maxLength = MAX_COMPLAINT_LENGTH;
229
+ what.required = true;
230
+ what.value = draft.description;
231
+ what.placeholder = "What went wrong, and when?";
232
+ what.addEventListener("input", () => draft.description = what.value);
233
+ form.appendChild(field(doc, "What happened?", what, { invalid: draft.checked && !draft.description.trim() }));
234
+ if (o.needsEmail) {
235
+ const email = el(doc, "input", "g-input");
236
+ email.type = "email";
237
+ email.autocomplete = "email";
238
+ email.required = true;
239
+ email.value = draft.email;
240
+ email.addEventListener("input", () => draft.email = email.value);
241
+ form.appendChild(field(doc, "Your email", email, { hint: "So the team can reply to you.", invalid: draft.checked && !EMAIL.test(draft.email.trim()) }));
242
+ }
243
+ const files = el(doc, "div", "g-m-attach");
244
+ if (draft.attachment) {
245
+ const a = draft.attachment;
246
+ const row = el(doc, "div", "g-m-row g-m-center g-m-attached");
247
+ if (a.previewUrl) {
248
+ const img = el(doc, "img", "g-m-thumb");
249
+ img.src = a.previewUrl;
250
+ img.alt = "";
251
+ img.addEventListener("error", () => img.remove());
252
+ row.appendChild(img);
253
+ }
254
+ row.appendChild(el(doc, "span", "g-m-grow g-m-ellipsis", a.fileName));
255
+ const remove = el(doc, "button", "g-m-text-btn", "Remove");
256
+ remove.type = "button";
257
+ remove.setAttribute("aria-label", `Remove ${a.fileName}`);
258
+ remove.addEventListener("click", o.onRemoveFile);
259
+ row.appendChild(remove);
260
+ files.appendChild(row);
261
+ } else {
262
+ const input = el(doc, "input", "g-m-file");
263
+ input.type = "file";
264
+ input.accept = "image/png,image/jpeg,image/webp,image/gif";
265
+ input.tabIndex = -1;
266
+ input.setAttribute("aria-hidden", "true");
267
+ input.addEventListener("change", () => {
268
+ const file = input.files?.[0];
269
+ if (file) o.onPickFile(file);
270
+ });
271
+ const pick = el(doc, "button", "g-m-attach-btn", "Attach a screenshot");
272
+ pick.type = "button";
273
+ pick.addEventListener("click", () => input.click());
274
+ files.append(input, pick);
275
+ }
276
+ form.appendChild(files);
277
+ const foot = el(doc, "div", "g-m-form-foot");
278
+ foot.appendChild(el(doc, "p", "g-m-hint", "We'll include the page you're on so the team can look into it."));
279
+ if (draft.error) {
280
+ const err = el(doc, "p", "g-m-error", draft.error);
281
+ err.setAttribute("role", "alert");
282
+ foot.appendChild(err);
283
+ }
284
+ const submit = el(doc, "button", "g-btn g-btn-primary g-m-wide", draft.sending ? "Sending\u2026" : "Submit complaint");
285
+ submit.type = "submit";
286
+ submit.disabled = draft.sending;
287
+ foot.appendChild(submit);
288
+ form.appendChild(foot);
289
+ form.addEventListener("submit", (e) => {
290
+ e.preventDefault();
291
+ if (!draft.sending) o.onSubmit();
292
+ });
293
+ return form;
294
+ }
295
+ function statusTracker(doc, status) {
296
+ const list = el(doc, "ol", "g-m-steps");
297
+ list.setAttribute("aria-label", "Progress");
298
+ const at = COMPLAINT_STEPS.findIndex(([k]) => k === status);
299
+ COMPLAINT_STEPS.forEach(([, label], i) => {
300
+ const li = el(doc, "li", `g-m-step${i <= at ? " g-m-step-done" : ""}`);
301
+ if (i === at) li.setAttribute("aria-current", "step");
302
+ li.append(el(doc, "span", "g-m-step-dot"), el(doc, "span", "g-m-step-label", label));
303
+ list.appendChild(li);
304
+ });
305
+ return list;
306
+ }
307
+ function complaintSent(doc, o) {
308
+ const wrap = el(doc, "div", "g-m-form g-m-sent");
309
+ const top = el(doc, "div", "g-m-sent-top");
310
+ const tick = el(doc, "span", "g-m-sent-tick");
311
+ tick.setAttribute("aria-hidden", "true");
312
+ tick.textContent = "\u2713";
313
+ const p = el(doc, "p", "g-m-card-sub");
314
+ p.append("Your reference is ", el(doc, "strong", "", o.complaint.reference), ". The team will reply in this conversation, so check back here.");
315
+ top.append(tick, el(doc, "h3", "g-m-form-title", "We've got your complaint"), p);
316
+ wrap.appendChild(top);
317
+ const details = el(doc, "dl", "g-m-card g-m-static g-m-details");
318
+ const row = (k, v) => {
319
+ const r = el(doc, "div", "g-m-detail");
320
+ r.append(el(doc, "dt", "", k), el(doc, "dd", "", v));
321
+ details.appendChild(r);
322
+ };
323
+ row("Category", o.complaint.categoryName);
324
+ o.answers.slice(0, 3).forEach(([k, v]) => row(k, v));
325
+ wrap.appendChild(details);
326
+ wrap.appendChild(statusTracker(doc, o.complaint.status));
327
+ const open = el(doc, "button", "g-btn g-btn-primary g-m-wide", "Add more detail");
328
+ open.type = "button";
329
+ open.addEventListener("click", o.onOpenConversation);
330
+ const home = el(doc, "button", "g-btn g-btn-secondary g-m-wide", "Back to home");
331
+ home.type = "button";
332
+ home.addEventListener("click", o.onHome);
333
+ wrap.append(open, home);
334
+ return wrap;
335
+ }
336
+ var MAX_SIDE = 1600;
337
+ async function readScreenshot(doc, file) {
338
+ if (!/^image\/(png|jpeg|webp|gif)$/.test(file.type)) throw new Error("Attach a PNG, JPEG, WebP or GIF image.");
339
+ if (file.size > 25 * 1024 * 1024) throw new Error("That image is too large.");
340
+ const win = doc.defaultView;
341
+ const asDataUrl = (blob) => new Promise((resolve, reject) => {
342
+ const r = new FileReader();
343
+ r.onload = () => resolve(String(r.result));
344
+ r.onerror = () => reject(new Error("The image couldn't be read."));
345
+ r.readAsDataURL(blob);
346
+ });
347
+ let dataUrl = null;
348
+ let scaled = false;
349
+ try {
350
+ const url = win?.URL.createObjectURL(file);
351
+ if (!url) throw new Error("no URL");
352
+ const img = await new Promise((resolve, reject) => {
353
+ const i = new Image();
354
+ i.onload = () => resolve(i);
355
+ i.onerror = () => reject(new Error("decode"));
356
+ i.src = url;
357
+ }).finally(() => win?.URL.revokeObjectURL(url));
358
+ const scale = Math.min(1, MAX_SIDE / Math.max(img.naturalWidth || 1, img.naturalHeight || 1));
359
+ const canvas = doc.createElement("canvas");
360
+ canvas.width = Math.max(1, Math.round(img.naturalWidth * scale));
361
+ canvas.height = Math.max(1, Math.round(img.naturalHeight * scale));
362
+ const ctx = canvas.getContext("2d");
363
+ if (!ctx) throw new Error("no canvas");
364
+ ctx.fillStyle = "#fff";
365
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
366
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
367
+ for (const q of [0.85, 0.7, 0.5]) {
368
+ dataUrl = canvas.toDataURL("image/jpeg", q);
369
+ if ((dataUrl.length - dataUrl.indexOf(",") - 1) * 0.75 <= MAX_COMPLAINT_ATTACHMENT_BYTES) break;
370
+ }
371
+ scaled = true;
372
+ } catch {
373
+ dataUrl = file.size <= MAX_COMPLAINT_ATTACHMENT_BYTES ? await asDataUrl(file) : null;
374
+ }
375
+ if (!dataUrl) throw new Error("That image is too large. Screenshots can be up to 2 MB.");
376
+ const data = dataUrl.slice(dataUrl.indexOf(",") + 1);
377
+ if (data.length * 0.75 > MAX_COMPLAINT_ATTACHMENT_BYTES) throw new Error("That image is too large. Screenshots can be up to 2 MB.");
378
+ const name = (file.name || "screenshot").slice(0, 180);
379
+ return { fileName: scaled ? `${name.replace(/\.[a-z0-9]+$/i, "")}.jpg` : name, data, previewUrl: dataUrl };
380
+ }
381
+
103
382
  // src/messenger-styles.ts
104
383
  var MESSENGER_STYLES = `
105
384
  :host([data-g-messenger="bottom-right"]) .g-launcher.g-pos-bottom-right{right:72px}
@@ -109,7 +388,15 @@ var MESSENGER_STYLES = `
109
388
  .g-m-launcher:focus-visible{outline:2px solid var(--g-primary);outline-offset:3px}
110
389
  .g-m-launcher svg{width:26px;height:26px;fill:currentColor}
111
390
  .g-m-badge{position:absolute;top:-2px;right:-2px;min-width:20px;height:20px;padding:0 5px;border-radius:999px;background:#e11d48;color:#fff;font-size:11px;font-weight:700;display:inline-flex;align-items:center;justify-content:center;border:2px solid var(--g-bg)}
112
- .g-messenger{bottom:76px;height:min(640px,calc(100vh - 110px))}
391
+ .g-messenger{bottom:76px;height:min(640px,calc(100vh - 110px));transition:width .2s ease,height .2s ease,max-height .2s ease}
392
+ .g-messenger.g-m-expanded{width:min(720px,calc(100vw - 32px));height:calc(100vh - 110px);max-height:calc(100vh - 110px)}
393
+ .g-m-expanded .g-rc-article,.g-m-expanded .g-m-scroll{padding:24px clamp(20px,5vw,40px)}
394
+ .g-m-expanded .g-rc-article>.g-title{font-size:24px;line-height:1.25;margin-bottom:12px}
395
+ .g-m-expanded .g-rc-article .g-body{font-size:15px}
396
+ .g-m-head.g-m-sizable{padding-right:92px}
397
+ .g-m-size{position:absolute;right:48px;top:50%;transform:translateY(-50%);width:40px;height:40px;border:0;border-radius:999px;background:transparent;color:inherit;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0}
398
+ .g-m-size .g-m-ico{width:18px;height:18px}
399
+ .g-m-size:hover,.g-m-size:focus-visible{background:rgba(255,255,255,.18);outline:none}
113
400
  .g-m-head{padding:16px 48px 14px 20px;background:var(--g-primary);color:var(--g-primary-text);position:relative;display:flex;align-items:center;gap:8px;min-height:56px}
114
401
  .g-m-head .g-close{color:inherit;top:50%;transform:translateY(-50%)}
115
402
  .g-m-head .g-close:hover,.g-m-head .g-close:focus-visible{background:rgba(255,255,255,.18);color:inherit}
@@ -179,6 +466,7 @@ var MESSENGER_STYLES = `
179
466
  .g-howto-desc,.g-howto-note{color:var(--g-muted);font-size:13px}
180
467
  .g-howto p{margin:0 0 6px;white-space:pre-line}
181
468
  .g-howto-show{width:100%;margin:6px 0 4px}
469
+ .g-howto-video{display:block;width:100%;max-height:260px;margin:8px 0 4px;border-radius:8px;background:#000}
182
470
  .g-howto-steps{list-style:none;margin:12px 0 0;padding:0;display:flex;flex-direction:column;gap:16px}
183
471
  .g-howto-step h4{margin:4px 0 0;font-size:15px}
184
472
  .g-howto-tip,.g-howto-alert{border-left:3px solid var(--g-primary);background:var(--g-subtle);border-radius:6px;padding:8px 10px;font-size:13px}
@@ -248,6 +536,52 @@ var MESSENGER_STYLES = `
248
536
  .g-m-asks{align-items:flex-start}
249
537
  .g-m-chip.g-m-ask{border-color:var(--g-primary);color:var(--g-primary);font-weight:600;border-radius:999px;padding:8px 14px;min-height:36px}
250
538
  .g-m-chip.g-m-ask:disabled{opacity:.5;cursor:not-allowed}
539
+ .g-m-side{display:flex;flex-direction:column;align-items:flex-end;gap:4px;flex:none}
540
+ .g-m-pill{display:inline-block;font-size:11px;font-weight:700;line-height:1.5;padding:1px 8px;border-radius:999px;white-space:nowrap;background:#fef3c7;color:#92400e}
541
+ .g-m-pill-in_review{background:#e0e7ff;color:#3730a3}
542
+ .g-m-pill-resolved{background:#dcfce7;color:#166534}
543
+ .g-m-complain .g-m-badge-ico{background:#fef3e2;color:#b45309}
544
+ .g-m-complaint{align-self:stretch;gap:10px;border:1px solid var(--g-border);background:var(--g-subtle);border-radius:10px;padding:10px 12px}
545
+ .g-m-chip-ico{width:16px;height:16px}
546
+ .g-m-offer{gap:6px}
547
+ .g-m-form{display:flex;flex-direction:column;gap:14px;margin:0}
548
+ .g-m-form-title{margin:0;font-size:17px;font-weight:600}
549
+ .g-m-cats{display:flex;flex-direction:column;gap:10px}
550
+ .g-m-cats .g-m-card{margin:0;gap:12px;min-height:44px}
551
+ .g-m-cat-dot{width:10px;height:10px;border-radius:999px;background:var(--g-muted);flex:none}
552
+ .g-m-picked{gap:8px;border:1px solid var(--g-border);background:var(--g-subtle);border-radius:10px;padding:8px 12px}
553
+ .g-m-text-btn{border:0;background:transparent;color:var(--g-primary);font:inherit;font-size:13px;font-weight:600;cursor:pointer;padding:6px 4px;min-height:32px}
554
+ .g-m-text-btn:hover{text-decoration:underline}
555
+ .g-m-text-btn:focus-visible{outline:2px solid var(--g-primary);outline-offset:2px;border-radius:4px}
556
+ .g-m-field{display:flex;flex-direction:column;gap:4px}
557
+ .g-m-label{font-weight:600;font-size:13px}
558
+ .g-m-optional{font-weight:400;color:var(--g-muted)}
559
+ .g-m-hint{color:var(--g-muted);font-size:12px;margin:0}
560
+ .g-m-error{color:#b91c1c;font-size:13px;margin:0}
561
+ .g-scope[data-theme="dark"] .g-m-error{color:#fca5a5}
562
+ .g-m-field .g-input[aria-invalid="true"]{border-color:#dc2626}
563
+ .g-m-textarea{min-height:110px}
564
+ .g-m-file{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none}
565
+ .g-m-attach-btn{align-self:flex-start;border:1px dashed var(--g-border);background:var(--g-bg);color:var(--g-text);font:inherit;font-size:13px;border-radius:8px;padding:8px 12px;cursor:pointer;min-height:36px}
566
+ .g-m-attach-btn:hover{background:var(--g-subtle)}
567
+ .g-m-attach-btn:focus-visible{outline:2px solid var(--g-primary);outline-offset:2px}
568
+ .g-m-attached{gap:10px;border:1px solid var(--g-border);border-radius:8px;padding:6px 8px;font-size:13px}
569
+ .g-m-thumb{width:40px;height:40px;object-fit:cover;border-radius:6px;background:var(--g-subtle);flex:none}
570
+ .g-m-form-foot{display:flex;flex-direction:column;gap:10px;margin-top:4px}
571
+ .g-m-sent{gap:12px}
572
+ .g-m-sent-top{display:flex;flex-direction:column;align-items:center;text-align:center;gap:6px;padding-top:8px}
573
+ .g-m-sent-top p{max-width:300px;margin:0}
574
+ .g-m-sent-tick{width:52px;height:52px;border-radius:999px;background:#dcfce7;color:#15803d;display:inline-flex;align-items:center;justify-content:center;font-size:26px;font-weight:700}
575
+ .g-m-details{margin:0;display:flex;flex-direction:column;gap:8px}
576
+ .g-m-detail{display:flex;justify-content:space-between;gap:12px;font-size:13px}
577
+ .g-m-detail dt{color:var(--g-muted)}
578
+ .g-m-detail dd{margin:0;font-weight:600;text-align:right;overflow-wrap:anywhere}
579
+ .g-m-steps{list-style:none;margin:4px 0;padding:0;display:flex;justify-content:space-between;position:relative}
580
+ .g-m-steps::before{content:"";position:absolute;top:6px;left:12%;right:12%;height:2px;background:var(--g-border)}
581
+ .g-m-step{position:relative;display:flex;flex-direction:column;align-items:center;gap:4px;flex:1;font-size:12px;color:var(--g-muted)}
582
+ .g-m-step-dot{width:14px;height:14px;border-radius:999px;border:2px solid var(--g-border);background:var(--g-bg);box-sizing:border-box}
583
+ .g-m-step-done{color:var(--g-text);font-weight:600}
584
+ .g-m-step-done .g-m-step-dot{background:var(--g-primary);border-color:var(--g-primary)}
251
585
  `;
252
586
 
253
587
  // src/render-safe.ts
@@ -353,7 +687,10 @@ var ICONS = {
353
687
  article: ["M6 3h9l4 4v14H6z", "M9 12h6", "M9 16h6"],
354
688
  ask: ["M4 5h16v11H8l-4 4z", "M9 9.5h6", "M9 12.5h4"],
355
689
  search: ["M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14z", "m20 20-3.5-3.5"],
356
- circle: ["M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z"]
690
+ circle: ["M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z"],
691
+ expand: ["M15 3h6v6", "M21 3l-7 7", "M9 21H3v-6", "M3 21l7-7"],
692
+ flag: ["M5 21V4", "M5 4h12l-2.5 4L17 12H5"],
693
+ collapse: ["M4 14h6v6", "M3 21l7-7", "M20 10h-6V4", "M21 3l-7 7"]
357
694
  };
358
695
  function icon(doc, name, cls = "g-m-ico") {
359
696
  const svg = doc.createElementNS(SVG_NS, "svg");
@@ -432,6 +769,7 @@ function renderMessenger(options) {
432
769
  let current = null;
433
770
  const streaming = /* @__PURE__ */ new Map();
434
771
  let sending = false;
772
+ let complaintDraft = emptyComplaintDraft();
435
773
  let draft = "";
436
774
  let articles = null;
437
775
  let articlesLoading = false;
@@ -506,8 +844,38 @@ function renderMessenger(options) {
506
844
  options.onOpenChecklist?.(id);
507
845
  },
508
846
  onArticleView: options.onArticleView,
509
- extra: (q) => howToItems(q)
847
+ extra: (q) => howToItems(q),
848
+ guides: () => options.guides ?? [],
849
+ onShowMe: (key) => startWalkthrough(key),
850
+ onArticleChange: () => syncSize()
510
851
  });
852
+ let docCollapsed = false;
853
+ let sizeButton = null;
854
+ const readingDoc = () => open && (view.kind === "howto" || view.kind === "tab" && view.tab === "help" && help.isArticleOpen());
855
+ function syncSize() {
856
+ const reading = readingDoc();
857
+ if (!reading) docCollapsed = false;
858
+ panel.classList.toggle("g-m-expanded", reading && !docCollapsed);
859
+ head.classList.toggle("g-m-sizable", reading);
860
+ if (!reading) {
861
+ sizeButton?.remove();
862
+ sizeButton = null;
863
+ return;
864
+ }
865
+ if (!sizeButton) {
866
+ sizeButton = el(doc, "button", "g-m-size");
867
+ sizeButton.type = "button";
868
+ sizeButton.addEventListener("click", () => {
869
+ docCollapsed = !docCollapsed;
870
+ syncSize();
871
+ });
872
+ head.appendChild(sizeButton);
873
+ }
874
+ sizeButton.textContent = "";
875
+ sizeButton.appendChild(icon(doc, docCollapsed ? "expand" : "collapse"));
876
+ sizeButton.setAttribute("aria-label", docCollapsed ? "Expand" : "Collapse");
877
+ sizeButton.title = docCollapsed ? "Expand" : "Collapse";
878
+ }
511
879
  const unreadTotal = () => conversations.reduce((n, c) => n + (c.unread > 0 ? 1 : 0), 0);
512
880
  const updateBadge = () => {
513
881
  const n = unreadTotal();
@@ -515,7 +883,13 @@ function renderMessenger(options) {
515
883
  badge.textContent = n > 9 ? "9+" : String(n);
516
884
  launcher.setAttribute("aria-label", n ? `Open ${config.name}, ${n} unread` : `Open ${config.name}`);
517
885
  };
518
- const upsertConversation = (c) => {
886
+ const withComplaint = (c) => {
887
+ if (c.complaint) return c;
888
+ const known = conversations.find((x) => x.id === c.id)?.complaint ?? (current?.id === c.id ? current.complaint : void 0);
889
+ return known ? { ...c, complaint: known } : c;
890
+ };
891
+ const upsertConversation = (incoming) => {
892
+ const c = withComplaint(incoming);
519
893
  conversations = [c, ...conversations.filter((x) => x.id !== c.id)].sort((a, b) => b.lastMessageAt.localeCompare(a.lastMessageAt));
520
894
  if (current?.id === c.id) current = c;
521
895
  updateBadge();
@@ -550,15 +924,19 @@ function renderMessenger(options) {
550
924
  const pageHowTos = () => (howTos?.forPage ?? []).map((id) => howTos?.howTos.find((h) => h.id === id)).filter((h) => !!h);
551
925
  const stepsLabel = (h) => `${h.stepCount} ${h.stepCount === 1 ? "step" : "steps"}`;
552
926
  const openHowTo = (id) => go({ kind: "howto", id, from: view });
927
+ const startWalkthrough = (guideKey) => {
928
+ setOpen(false);
929
+ options.onStartGuide?.(guideKey);
930
+ };
553
931
  function howToItems(q) {
554
- const list = q ? (howTos?.howTos ?? []).filter((h) => h.title.toLowerCase().includes(q) || h.description.toLowerCase().includes(q)).slice(0, 10) : [];
932
+ const list = q ? (howTos?.howTos ?? []).filter((h) => matchesHelpQuery(`${h.title} ${h.description}`, q)).slice(0, 10) : [];
555
933
  if (!list.length) return [];
556
934
  return [
557
935
  el(doc, "div", "g-rc-section", `How-tos (${list.length})`),
558
936
  ...list.map((h) => {
559
- const b = listItem(doc, "\u{1F4CB}", h.title, h.description || stepsLabel(h));
560
- b.addEventListener("click", () => openHowTo(h.id));
561
- return b;
937
+ const key = h.guideKey && startableGuide(options.guides, h.guideKey) ? h.guideKey : null;
938
+ const sub = [h.hasVideo ? "Video" : "", stepsLabel(h)].filter(Boolean).join(" \xB7 ");
939
+ return resultRow(doc, "\u{1F4CB}", h.title, h.description ? `${sub} \xB7 ${h.description}` : sub, () => openHowTo(h.id), key ? () => startWalkthrough(key) : void 0);
562
940
  })
563
941
  ];
564
942
  }
@@ -570,6 +948,7 @@ function renderMessenger(options) {
570
948
  });
571
949
  function renderHead(title, back) {
572
950
  head.textContent = "";
951
+ sizeButton = null;
573
952
  head.hidden = false;
574
953
  if (back) {
575
954
  const b = el(doc, "button", "g-m-back");
@@ -609,9 +988,9 @@ function renderMessenger(options) {
609
988
  i.appendChild(icon(doc, ico));
610
989
  b.append(i, el(doc, "span", "", label));
611
990
  if (tab === "news" && latest.some((n) => n.unread)) {
612
- const dot = el(doc, "span", "g-m-tab-dot");
613
- dot.setAttribute("aria-label", "New");
614
- i.appendChild(dot);
991
+ const dot2 = el(doc, "span", "g-m-tab-dot");
992
+ dot2.setAttribute("aria-label", "New");
993
+ i.appendChild(dot2);
615
994
  }
616
995
  b.addEventListener("click", () => go({ kind: "tab", tab }));
617
996
  tabs.appendChild(b);
@@ -625,7 +1004,10 @@ function renderMessenger(options) {
625
1004
  const text = el(doc, "div", "g-m-grow");
626
1005
  if (label) text.appendChild(el(doc, "div", "g-m-card-label", label));
627
1006
  text.append(el(doc, "div", "g-m-card-title g-m-ellipsis", c.subject || "Conversation"), el(doc, "div", "g-m-card-sub g-m-ellipsis", c.lastMessagePreview));
628
- row.append(text, el(doc, "span", "g-m-time", relativeTime(c.lastMessageAt)));
1007
+ const side = el(doc, "span", "g-m-side");
1008
+ side.appendChild(el(doc, "span", "g-m-time", relativeTime(c.lastMessageAt)));
1009
+ if (c.complaint) side.appendChild(statusPill(doc, c.complaint.status));
1010
+ row.append(text, side);
629
1011
  card.appendChild(row);
630
1012
  card.setAttribute("aria-label", `${label ? `${label}: ` : ""}${c.subject || "Conversation"}${c.unread ? ", unread" : ""}`);
631
1013
  card.addEventListener("click", () => go({ kind: "thread", conversationId: c.id }));
@@ -836,7 +1218,10 @@ function renderMessenger(options) {
836
1218
  hello.append(el(doc, "span", "g-m-hello-1", v.firstName ? `Hi ${v.firstName}.` : "Hi there."), el(doc, "span", "g-m-hello-2", "How can we help?"));
837
1219
  inner.append(top, hello);
838
1220
  inner.appendChild(actionCard({ title: "Send us a message", sub: messageSub(), trail: true, onClick: () => go({ kind: "thread", conversationId: null }) }));
839
- const recent = conversations[0];
1221
+ if (canComplain()) inner.appendChild(actionCard({ title: "File a complaint", sub: "Tell us what went wrong. You'll get a reference.", lead: iconTile("flag"), onClick: () => startComplaint(null), cls: "g-m-complain" }));
1222
+ const filed = latestComplaint();
1223
+ if (filed) inner.appendChild(conversationCard(filed, "Your complaint"));
1224
+ const recent = conversations.find((c) => c.id !== filed?.id);
840
1225
  if (recent) inner.appendChild(conversationCard(recent, recent.status === "closed" && !recent.unread ? "Recent conversation" : "Continue the conversation"));
841
1226
  if (config.status) inner.appendChild(statusCard(config.status));
842
1227
  if (cards.progress) progressCards(v).forEach((c) => inner.appendChild(c));
@@ -861,6 +1246,102 @@ function renderMessenger(options) {
861
1246
  scroll.append(hero, inner);
862
1247
  main.appendChild(scroll);
863
1248
  }
1249
+ const complaintCategories = () => config.complaints?.categories ?? [];
1250
+ const needsEmail = () => visitor().identified === false;
1251
+ const canComplain = () => !!api.fileComplaint && complaintCategories().length > 0 && (!needsEmail() || !!config.complaints?.anonymous);
1252
+ function latestComplaint() {
1253
+ let best = null;
1254
+ for (const c of conversations) if (c.complaint && (!best || c.complaint.createdAt > best.complaint.createdAt)) best = c;
1255
+ return best;
1256
+ }
1257
+ function userMessagesText() {
1258
+ return messages.filter((m) => m.author === "user" && m.body.trim()).slice(-3).map((m) => m.body.trim()).join("\n\n").slice(0, 4e3);
1259
+ }
1260
+ function startComplaint(categoryId) {
1261
+ const cats = complaintCategories();
1262
+ const picked = categoryId ? cats.find((c) => c.id === categoryId) : cats.length === 1 ? cats[0] : void 0;
1263
+ const from = view.kind === "tab" || view.kind === "thread" ? view : { kind: "tab", tab: "home" };
1264
+ complaintDraft = emptyComplaintDraft(complaintDraft.description);
1265
+ if (picked) go({ kind: "complaint-form", categoryId: picked.id, conversationId: null, from });
1266
+ else go({ kind: "complaint-category", conversationId: null, from });
1267
+ }
1268
+ function complaintBanner(c) {
1269
+ const b = el(doc, "div", "g-m-complaint g-m-row g-m-center");
1270
+ b.setAttribute("role", "status");
1271
+ b.appendChild(icon(doc, "flag", "g-m-ico g-m-lead"));
1272
+ const text = el(doc, "span", "g-m-grow");
1273
+ text.append(el(doc, "span", "g-m-card-title", c.reference), el(doc, "span", "g-m-card-sub", c.categoryName));
1274
+ b.append(text, statusPill(doc, c.status));
1275
+ return b;
1276
+ }
1277
+ function renderComplaintCategories(v) {
1278
+ renderHead("File a complaint", () => go(v.from));
1279
+ const scroll = el(doc, "div", "g-m-scroll");
1280
+ scroll.appendChild(categoryList(doc, complaintCategories(), (c) => go({ kind: "complaint-form", categoryId: c.id, conversationId: v.conversationId, from: v.from })));
1281
+ main.appendChild(scroll);
1282
+ }
1283
+ function renderComplaintForm(v) {
1284
+ const category = complaintCategories().find((c) => c.id === v.categoryId);
1285
+ if (!category) return go({ kind: "complaint-category", conversationId: v.conversationId, from: v.from });
1286
+ const back = () => complaintCategories().length > 1 && !v.conversationId ? go({ kind: "complaint-category", conversationId: v.conversationId, from: v.from }) : go(v.from);
1287
+ renderHead("File a complaint", back);
1288
+ const scroll = el(doc, "div", "g-m-scroll");
1289
+ const draft2 = complaintDraft;
1290
+ scroll.appendChild(
1291
+ complaintForm(doc, {
1292
+ category,
1293
+ draft: draft2,
1294
+ needsEmail: needsEmail(),
1295
+ canChangeCategory: complaintCategories().length > 1,
1296
+ onChangeCategory: () => go({ kind: "complaint-category", conversationId: v.conversationId, from: v.from }),
1297
+ onPickFile: (file) => {
1298
+ draft2.error = null;
1299
+ readScreenshot(doc, file).then((a) => draft2.attachment = a).catch((e) => draft2.error = e instanceof Error ? e.message : "That image couldn't be attached.").finally(() => {
1300
+ if (view === v) render();
1301
+ });
1302
+ },
1303
+ onRemoveFile: () => {
1304
+ draft2.attachment = null;
1305
+ render();
1306
+ },
1307
+ onSubmit: () => submitComplaint(v, category)
1308
+ })
1309
+ );
1310
+ main.appendChild(scroll);
1311
+ }
1312
+ function submitComplaint(v, category) {
1313
+ const draft2 = complaintDraft;
1314
+ const problem = complaintProblem(category, draft2, needsEmail());
1315
+ draft2.checked = true;
1316
+ draft2.error = problem;
1317
+ if (problem || !api.fileComplaint) return render();
1318
+ draft2.sending = true;
1319
+ render();
1320
+ const answers = complaintAnswers(category, draft2);
1321
+ api.fileComplaint({
1322
+ categoryId: category.id,
1323
+ description: draft2.description.trim(),
1324
+ answers,
1325
+ email: needsEmail() ? draft2.email.trim() : null,
1326
+ conversationId: v.conversationId,
1327
+ attachment: draft2.attachment ? { fileName: draft2.attachment.fileName, data: draft2.attachment.data } : null
1328
+ }).then((res) => {
1329
+ upsertConversation({ ...res.conversation, unread: 0 });
1330
+ complaintDraft = emptyComplaintDraft();
1331
+ const labels = category.fields.filter((f) => answers[f.id]).map((f) => [f.label, answers[f.id]]);
1332
+ if (view === v) go({ kind: "complaint-sent", complaint: res.complaint, conversationId: res.conversation.id, answers: labels });
1333
+ }).catch((e) => {
1334
+ draft2.sending = false;
1335
+ draft2.error = (e instanceof Error && e.message ? e.message : "Your complaint couldn't be sent.").replace(/^[\w.]+: /, "");
1336
+ if (view === v) render();
1337
+ });
1338
+ }
1339
+ function renderComplaintSent(v) {
1340
+ renderHead(v.complaint.reference, () => go({ kind: "tab", tab: "home" }));
1341
+ const scroll = el(doc, "div", "g-m-scroll");
1342
+ scroll.appendChild(complaintSent(doc, { complaint: v.complaint, answers: v.answers, onOpenConversation: () => go({ kind: "thread", conversationId: v.conversationId }), onHome: () => go({ kind: "tab", tab: "home" }) }));
1343
+ main.appendChild(scroll);
1344
+ }
864
1345
  function renderHowToView(id, from) {
865
1346
  const cached = howToCache.get(id);
866
1347
  renderHead(cached?.title ?? "How-to", () => go(from));
@@ -973,8 +1454,20 @@ function renderMessenger(options) {
973
1454
  if (m.status === "pending") bubble.setAttribute("aria-busy", "true");
974
1455
  bubble.dataset.messageId = m.id;
975
1456
  wrap.appendChild(bubble);
976
- if (m.citations.length || m.suggestedGuides.length || m.howTos?.length) {
1457
+ const offer = m.complaintOffer && canComplain() ? complaintCategories().find((c) => c.id === m.complaintOffer.categoryId) : void 0;
1458
+ if (m.citations.length || m.suggestedGuides.length || m.howTos?.length || offer) {
977
1459
  const chips = el(doc, "div", "g-m-chips");
1460
+ if (offer) {
1461
+ const b = el(doc, "button", "g-m-chip g-m-guide g-m-offer");
1462
+ b.type = "button";
1463
+ b.append(icon(doc, "flag", "g-m-ico g-m-chip-ico"), el(doc, "span", "", `File a complaint \xB7 ${offer.name}`));
1464
+ b.addEventListener("click", () => {
1465
+ const conversationId = current?.id ?? null;
1466
+ complaintDraft = emptyComplaintDraft(userMessagesText());
1467
+ go({ kind: "complaint-form", categoryId: offer.id, conversationId, from: view });
1468
+ });
1469
+ chips.appendChild(b);
1470
+ }
978
1471
  for (const g of m.suggestedGuides) {
979
1472
  const b = el(doc, "button", "g-m-chip g-m-guide", `\u25B6 Show me: ${g.name}`);
980
1473
  b.type = "button";
@@ -1004,6 +1497,7 @@ function renderMessenger(options) {
1004
1497
  if (!log) return;
1005
1498
  const atBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 40;
1006
1499
  log.textContent = "";
1500
+ if (current?.complaint) log.appendChild(complaintBanner(current.complaint));
1007
1501
  if (!messages.length) {
1008
1502
  const v = visitor();
1009
1503
  const label = v.path ? pageLabel(v.path) : null;
@@ -1123,6 +1617,9 @@ function renderMessenger(options) {
1123
1617
  sendButton = null;
1124
1618
  if (view.kind === "thread") renderThread(view.conversationId);
1125
1619
  else if (view.kind === "howto") renderHowToView(view.id, view.from);
1620
+ else if (view.kind === "complaint-category") renderComplaintCategories(view);
1621
+ else if (view.kind === "complaint-form") renderComplaintForm(view);
1622
+ else if (view.kind === "complaint-sent") renderComplaintSent(view);
1126
1623
  else if (view.tab === "home") renderHome();
1127
1624
  else if (view.tab === "messages") renderMessagesTab();
1128
1625
  else if (view.tab === "news" && news().length) renderNewsTab();
@@ -1131,6 +1628,7 @@ function renderMessenger(options) {
1131
1628
  renderHome();
1132
1629
  } else renderHelpTab();
1133
1630
  renderTabs();
1631
+ syncSize();
1134
1632
  }
1135
1633
  const handlers = (seq) => ({
1136
1634
  onConversation(c) {
@@ -1159,8 +1657,8 @@ function renderMessenger(options) {
1159
1657
  upsertMessage(m);
1160
1658
  options.onMessage?.(m, c);
1161
1659
  }
1162
- upsertConversation({ ...c, unread: 0 });
1163
- current = { ...c, unread: 0 };
1660
+ current = withComplaint({ ...c, unread: 0 });
1661
+ upsertConversation(current);
1164
1662
  },
1165
1663
  onError() {
1166
1664
  }
@@ -1213,8 +1711,8 @@ function renderMessenger(options) {
1213
1711
  return !prev || prev.status !== m.status || prev.body !== m.body;
1214
1712
  });
1215
1713
  res.messages.forEach(upsertMessage);
1216
- upsertConversation({ ...res.conversation, unread: 0 });
1217
- current = { ...res.conversation, unread: 0 };
1714
+ current = withComplaint({ ...res.conversation, unread: 0 });
1715
+ upsertConversation(current);
1218
1716
  if (changed || messages.length !== before) {
1219
1717
  renderLog();
1220
1718
  res.messages.filter((m) => m.author !== "user").forEach((m) => options.onMessage?.(m, res.conversation));
@@ -1240,7 +1738,7 @@ function renderMessenger(options) {
1240
1738
  api.loadMessages(next.conversationId).then((res) => {
1241
1739
  if (seq !== threadSeq) return;
1242
1740
  res.messages.forEach(upsertMessage);
1243
- current = { ...res.conversation, unread: 0 };
1741
+ current = withComplaint({ ...res.conversation, unread: 0 });
1244
1742
  upsertConversation(current);
1245
1743
  renderHead(current.subject || "Conversation", () => go({ kind: "tab", tab: "messages" }));
1246
1744
  renderLog();
@@ -1278,6 +1776,7 @@ function renderMessenger(options) {
1278
1776
  } else {
1279
1777
  if (threadTimer) clearTimeout(threadTimer);
1280
1778
  threadTimer = null;
1779
+ syncSize();
1281
1780
  options.onClose?.();
1282
1781
  }
1283
1782
  };
@@ -1315,6 +1814,11 @@ function renderMessenger(options) {
1315
1814
  setOpen(true);
1316
1815
  openArticle(id);
1317
1816
  },
1817
+ openComplaint(categoryId) {
1818
+ if (!canComplain()) return;
1819
+ setOpen(true);
1820
+ startComplaint(categoryId ?? null);
1821
+ },
1318
1822
  refresh() {
1319
1823
  if (destroyed) return;
1320
1824
  if (!open) {