@goflowstate/widget-sdk 0.3.1 → 0.3.4
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 +24 -2
- package/dist/canvas-widget-sdk.js +534 -28
- package/dist/canvas-widget-sdk.js.map +3 -3
- package/dist/feedback.d.ts +81 -1
- package/dist/feedback.d.ts.map +1 -1
- package/dist/index.mjs +534 -28
- package/dist/index.mjs.map +3 -3
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// @goflowstate/widget-sdk v0.3.
|
|
1
|
+
// @goflowstate/widget-sdk v0.3.4
|
|
2
2
|
// https://github.com/goflowstate/goflowstate-sdk
|
|
3
3
|
|
|
4
4
|
var __defProp = Object.defineProperty;
|
|
@@ -121,6 +121,46 @@ var primaryButton = (doc, ground) => {
|
|
|
121
121
|
});
|
|
122
122
|
return best;
|
|
123
123
|
};
|
|
124
|
+
var TEXT_SAMPLE_LIMIT = 600;
|
|
125
|
+
var DISPLAY_MIN_PX = 22;
|
|
126
|
+
var sizeOf = (style) => {
|
|
127
|
+
const n = parseFloat(style.fontSize);
|
|
128
|
+
return Number.isFinite(n) && n > 0 ? n : 16;
|
|
129
|
+
};
|
|
130
|
+
var textSamples = (doc) => {
|
|
131
|
+
const out = [];
|
|
132
|
+
const all = doc.body.querySelectorAll("*");
|
|
133
|
+
for (let i = 0; i < all.length && out.length < TEXT_SAMPLE_LIMIT; i += 1) {
|
|
134
|
+
const el = all[i];
|
|
135
|
+
if (el.tagName === "SCRIPT" || el.tagName === "STYLE" || inOurs(el)) continue;
|
|
136
|
+
let chars = 0;
|
|
137
|
+
for (const node of Array.from(el.childNodes)) {
|
|
138
|
+
if (node.nodeType === 3) chars += (node.textContent ?? "").trim().length;
|
|
139
|
+
}
|
|
140
|
+
if (chars === 0 || !visible(el)) continue;
|
|
141
|
+
const style = styleOf(el);
|
|
142
|
+
if (!style.fontFamily) continue;
|
|
143
|
+
out.push({
|
|
144
|
+
family: style.fontFamily,
|
|
145
|
+
size: sizeOf(style),
|
|
146
|
+
chars,
|
|
147
|
+
style,
|
|
148
|
+
heading: /^H[1-3]$/.test(el.tagName)
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
};
|
|
153
|
+
var trackingEm = (style, min, max, fallback) => {
|
|
154
|
+
const px = style ? parseFloat(style.letterSpacing) : NaN;
|
|
155
|
+
const em = style && Number.isFinite(px) ? px / sizeOf(style) : fallback;
|
|
156
|
+
const held = Math.min(max, Math.max(min, em));
|
|
157
|
+
return held === 0 ? "0em" : `${Math.round(held * 1e3) / 1e3}em`;
|
|
158
|
+
};
|
|
159
|
+
var weightIn = (style, min, max, fallback) => {
|
|
160
|
+
const raw = style?.fontWeight ?? "";
|
|
161
|
+
const n = raw === "bold" ? 700 : raw === "normal" ? 400 : parseFloat(raw);
|
|
162
|
+
return Number.isFinite(n) ? String(Math.min(max, Math.max(min, n))) : fallback;
|
|
163
|
+
};
|
|
124
164
|
function readFlowTheme(doc = document) {
|
|
125
165
|
const body = doc.body;
|
|
126
166
|
const stage = body.querySelector(":scope > :not(script):not(style):not([data-fs-feedback-host])");
|
|
@@ -143,12 +183,32 @@ function readFlowTheme(doc = document) {
|
|
|
143
183
|
const pinnedCtaInk = parseColor(rootVar(doc, ["--fs-feedback-cta-ink"]));
|
|
144
184
|
const sampledCtaInk = !pinnedCta && sampledCta && buttonStyle ? parseColor(buttonStyle.color) : null;
|
|
145
185
|
const ctaInk = pinnedCtaInk ?? (sampledCtaInk && contrast(sampledCtaInk, cta) >= 3 ? sampledCtaInk : null) ?? (luminance(cta) > 0.4 ? BLACK : WHITE);
|
|
146
|
-
const
|
|
186
|
+
const samples = textSamples(doc);
|
|
187
|
+
const headings = Array.from(doc.querySelectorAll('h1, h2, [class*="title"]')).filter(
|
|
147
188
|
(el) => !inOurs(el) && visible(el)
|
|
148
189
|
);
|
|
149
|
-
const
|
|
150
|
-
|
|
190
|
+
const largest = (items, size) => items.reduce(
|
|
191
|
+
(best, item) => best === null || size(item) > size(best) ? item : best,
|
|
192
|
+
null
|
|
193
|
+
);
|
|
194
|
+
const bigHeading = largest(
|
|
195
|
+
headings.filter((el) => sizeOf(styleOf(el)) >= DISPLAY_MIN_PX),
|
|
196
|
+
(el) => sizeOf(styleOf(el))
|
|
197
|
+
);
|
|
198
|
+
const bigLine = largest(
|
|
199
|
+
samples.filter((t) => t.size >= DISPLAY_MIN_PX && t.chars >= 3),
|
|
200
|
+
(t) => t.size
|
|
201
|
+
);
|
|
202
|
+
const headingStyle = bigHeading ? styleOf(bigHeading) : bigLine?.style ?? (headings[0] ? styleOf(headings[0]) : null);
|
|
203
|
+
const byFamily = /* @__PURE__ */ new Map();
|
|
204
|
+
for (const t of samples) {
|
|
205
|
+
if (t.size < DISPLAY_MIN_PX && !t.heading)
|
|
206
|
+
byFamily.set(t.family, (byFamily.get(t.family) ?? 0) + t.chars);
|
|
207
|
+
}
|
|
208
|
+
const readBody = largest(Array.from(byFamily), ([, chars]) => chars)?.[0] ?? "";
|
|
209
|
+
const fontBody = rootVar(doc, ["--fs-feedback-font"]) || readBody || (stage ? styleOf(stage).fontFamily : "") || bodyStyle.fontFamily || rootVar(doc, ["--sb-font-body", "--font-body"]) || "system-ui, sans-serif";
|
|
151
210
|
const fontDisplay = rootVar(doc, ["--fs-feedback-font-display"]) || headingStyle?.fontFamily || rootVar(doc, ["--sb-font-display", "--sb-font-title", "--font-display"]) || fontBody;
|
|
211
|
+
const buttonType = buttonStyle?.fontFamily ? buttonStyle : null;
|
|
152
212
|
const field = Array.from(
|
|
153
213
|
doc.querySelectorAll('textarea, input[type="text"], input:not([type])')
|
|
154
214
|
).find((el) => !inOurs(el) && visible(el));
|
|
@@ -172,6 +232,11 @@ function readFlowTheme(doc = document) {
|
|
|
172
232
|
fontBody,
|
|
173
233
|
fontDisplay,
|
|
174
234
|
displayWeight: headingStyle?.fontWeight || "500",
|
|
235
|
+
displayTracking: trackingEm(headingStyle, -0.04, 0.02, -0.01),
|
|
236
|
+
fontButton: buttonType?.fontFamily || fontBody,
|
|
237
|
+
buttonWeight: weightIn(buttonType, 500, 800, "600"),
|
|
238
|
+
buttonTracking: trackingEm(buttonType, -0.02, 0.08, 0),
|
|
239
|
+
buttonTransform: buttonType?.textTransform === "uppercase" ? "uppercase" : "none",
|
|
175
240
|
radius,
|
|
176
241
|
buttonRadius: buttonRadius === null ? "999px" : `${Math.min(buttonRadius, 999)}px`,
|
|
177
242
|
dark
|
|
@@ -197,9 +262,10 @@ function encodeScreenshot(canvas) {
|
|
|
197
262
|
return null;
|
|
198
263
|
}
|
|
199
264
|
async function captureViewport(ground) {
|
|
200
|
-
const { domToCanvas } = await import("modern-screenshot");
|
|
201
265
|
const width = window.innerWidth;
|
|
202
266
|
const height = window.innerHeight;
|
|
267
|
+
const { scrollX, scrollY } = window;
|
|
268
|
+
const { domToCanvas } = await import("modern-screenshot");
|
|
203
269
|
const scale = Math.min(
|
|
204
270
|
window.devicePixelRatio || 1,
|
|
205
271
|
SCREENSHOT_MAX_SIDE / Math.max(width, height)
|
|
@@ -209,7 +275,7 @@ async function captureViewport(ground) {
|
|
|
209
275
|
height,
|
|
210
276
|
scale,
|
|
211
277
|
backgroundColor: ground,
|
|
212
|
-
style: { transform: `translate(${-
|
|
278
|
+
style: { transform: `translate(${-scrollX}px, ${-scrollY}px)` },
|
|
213
279
|
filter: (node) => !(node instanceof Element && node.hasAttribute(FEEDBACK_HOST_ATTR))
|
|
214
280
|
});
|
|
215
281
|
return encodeScreenshot(canvas);
|
|
@@ -227,21 +293,168 @@ var withTimeout = (work, ms) => new Promise((resolve) => {
|
|
|
227
293
|
}
|
|
228
294
|
);
|
|
229
295
|
});
|
|
296
|
+
var PLACEMENT_BASE = 16;
|
|
297
|
+
var PLACEMENT_STEP = 8;
|
|
298
|
+
var PLACEMENT_PAD = 8;
|
|
299
|
+
var PLACEMENT_MAX_SHARE = 0.6;
|
|
300
|
+
var PLACEMENT_READ_COST = 240;
|
|
301
|
+
var EMPTY = 0;
|
|
302
|
+
var READ = 1;
|
|
303
|
+
var TAP = 2;
|
|
304
|
+
var SAMPLES = 5;
|
|
305
|
+
function choosePlacement(box, viewportHeight, at) {
|
|
306
|
+
const max = Math.max(PLACEMENT_BASE, viewportHeight * PLACEMENT_MAX_SHARE);
|
|
307
|
+
const span = box.size + PLACEMENT_PAD * 2;
|
|
308
|
+
const offsets = Array.from({ length: SAMPLES }, (_, i) => span * i / (SAMPLES - 1));
|
|
309
|
+
const x0 = box.left - PLACEMENT_PAD;
|
|
310
|
+
let best = PLACEMENT_BASE;
|
|
311
|
+
let bestCost = Infinity;
|
|
312
|
+
for (let bottom = PLACEMENT_BASE; bottom <= max; bottom += PLACEMENT_STEP) {
|
|
313
|
+
if (bottom >= bestCost) break;
|
|
314
|
+
const y0 = box.top - (bottom - box.bottom) - PLACEMENT_PAD;
|
|
315
|
+
let worst = EMPTY;
|
|
316
|
+
for (const dy of offsets) {
|
|
317
|
+
for (const dx of offsets) {
|
|
318
|
+
const found = at(x0 + dx, y0 + dy);
|
|
319
|
+
if (found > worst) worst = found;
|
|
320
|
+
if (worst === TAP) break;
|
|
321
|
+
}
|
|
322
|
+
if (worst === TAP) break;
|
|
323
|
+
}
|
|
324
|
+
if (worst === TAP) continue;
|
|
325
|
+
const cost = bottom + (worst === READ ? PLACEMENT_READ_COST : 0);
|
|
326
|
+
if (cost < bestCost) {
|
|
327
|
+
bestCost = cost;
|
|
328
|
+
best = bottom;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return best;
|
|
332
|
+
}
|
|
333
|
+
var TAPPABLE = 'a[href],button,input,select,textarea,summary,label,[role="button"],[role="link"],[role="tab"],[role="switch"],[role="checkbox"],[role="radio"],[role="slider"],[role="option"],[tabindex]:not([tabindex="-1"]),[contenteditable=""],[contenteditable="true"],[onclick]';
|
|
334
|
+
var PICTURE = "img,svg,canvas,video,picture";
|
|
335
|
+
var SURFACE_SHARE = 0.8;
|
|
336
|
+
function occupancyReader(host, doc = document) {
|
|
337
|
+
if (typeof doc.elementsFromPoint !== "function") return () => EMPTY;
|
|
338
|
+
const view = doc.defaultView;
|
|
339
|
+
const width = view?.innerWidth ?? 1;
|
|
340
|
+
const height = view?.innerHeight ?? 1;
|
|
341
|
+
const seen = /* @__PURE__ */ new Map();
|
|
342
|
+
const read = (el) => {
|
|
343
|
+
const known = seen.get(el);
|
|
344
|
+
if (known) return known;
|
|
345
|
+
const style = view ? view.getComputedStyle(el) : null;
|
|
346
|
+
let tap = el.matches(TAPPABLE) || style?.cursor === "pointer";
|
|
347
|
+
if (tap) {
|
|
348
|
+
const r = el.getBoundingClientRect();
|
|
349
|
+
if (r.width * r.height >= width * height * SURFACE_SHARE) tap = false;
|
|
350
|
+
}
|
|
351
|
+
const text = [];
|
|
352
|
+
if (typeof doc.createRange === "function") {
|
|
353
|
+
for (const node of Array.from(el.childNodes)) {
|
|
354
|
+
if (node.nodeType !== 3 || !node.nodeValue || !node.nodeValue.trim()) continue;
|
|
355
|
+
const range = doc.createRange();
|
|
356
|
+
range.selectNodeContents(node);
|
|
357
|
+
if (typeof range.getClientRects === "function")
|
|
358
|
+
text.push(...Array.from(range.getClientRects()));
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
const ground = parseColor(style?.backgroundColor);
|
|
362
|
+
const image = style?.backgroundImage ?? "";
|
|
363
|
+
const reading = {
|
|
364
|
+
tap,
|
|
365
|
+
picture: el.matches(PICTURE) || el.closest("svg") !== null,
|
|
366
|
+
text,
|
|
367
|
+
opaque: ground !== null && ground[3] >= 0.5 || image !== "" && image !== "none"
|
|
368
|
+
};
|
|
369
|
+
seen.set(el, reading);
|
|
370
|
+
return reading;
|
|
371
|
+
};
|
|
372
|
+
return (x, y) => {
|
|
373
|
+
const cx = Math.min(Math.max(x, 0), width - 1);
|
|
374
|
+
const cy = Math.min(Math.max(y, 0), height - 1);
|
|
375
|
+
const stack = doc.elementsFromPoint(cx, cy).filter((el) => el !== host && el !== doc.documentElement && el !== doc.body);
|
|
376
|
+
for (let el = stack[0] ?? null; el && el !== doc.body; el = el.parentElement) {
|
|
377
|
+
if (read(el).tap) return TAP;
|
|
378
|
+
}
|
|
379
|
+
for (const el of stack) {
|
|
380
|
+
const r = read(el);
|
|
381
|
+
if (r.picture || r.text.some((t) => cx >= t.left && cx <= t.right && cy >= t.top && cy <= t.bottom))
|
|
382
|
+
return READ;
|
|
383
|
+
if (r.opaque) break;
|
|
384
|
+
}
|
|
385
|
+
return EMPTY;
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
var BUTTON_SIZE = 44;
|
|
389
|
+
var TAB_WIDTH = 26;
|
|
390
|
+
var TAB_HEIGHT = 54;
|
|
391
|
+
var EDGE_MARGIN = 12;
|
|
392
|
+
var DOCK_PAST = 14;
|
|
393
|
+
var SPOT_KEY = "fs-feedback-spot";
|
|
394
|
+
var projectMomentum = (v, deceleration = 0.992) => v / 1e3 * deceleration / (1 - deceleration);
|
|
395
|
+
var rubberband = (overshoot, dimension, constant = 0.55) => overshoot * dimension * constant / (dimension + constant * Math.abs(overshoot));
|
|
396
|
+
var clampY = (y, height, view) => Math.min(
|
|
397
|
+
Math.max(y, view.top + EDGE_MARGIN),
|
|
398
|
+
Math.max(view.top + EDGE_MARGIN, view.height - view.bottom - EDGE_MARGIN - height)
|
|
399
|
+
);
|
|
400
|
+
function restingSpot(state, left, y, view) {
|
|
401
|
+
if (state === "float") {
|
|
402
|
+
return {
|
|
403
|
+
state,
|
|
404
|
+
x: left ? EDGE_MARGIN : view.width - BUTTON_SIZE - EDGE_MARGIN,
|
|
405
|
+
y: clampY(y, BUTTON_SIZE, view)
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
return { state, x: left ? 0 : view.width - TAB_WIDTH, y: clampY(y, TAB_HEIGHT, view) };
|
|
409
|
+
}
|
|
410
|
+
function settleRelease(from, velocity, view) {
|
|
411
|
+
const py = from.y + projectMomentum(velocity.y);
|
|
412
|
+
if (from.state !== "float") return restingSpot(from.state, from.state === "dock-left", py, view);
|
|
413
|
+
const px = from.x + projectMomentum(velocity.x);
|
|
414
|
+
const maxX = view.width - BUTTON_SIZE;
|
|
415
|
+
const tabY = py + (BUTTON_SIZE - TAB_HEIGHT) / 2;
|
|
416
|
+
if (px < -DOCK_PAST || from.x < 3) return restingSpot("dock-left", true, tabY, view);
|
|
417
|
+
if (px > maxX + DOCK_PAST || from.x > maxX - 3)
|
|
418
|
+
return restingSpot("dock-right", false, tabY, view);
|
|
419
|
+
return restingSpot("float", px + BUTTON_SIZE / 2 < view.width / 2, py, view);
|
|
420
|
+
}
|
|
230
421
|
var BUG_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="8" y="7" width="8" height="12" rx="4"/><path d="M9.5 7a2.5 2.5 0 0 1 5 0M12 11v8M4 13h4M16 13h4M5 7l3.2 2.4M19 7l-3.2 2.4M5 20l3.2-2.6M19 20l-3.2-2.6"/></svg>';
|
|
231
422
|
var STYLE = `
|
|
232
423
|
:host { all: initial; }
|
|
233
424
|
* { box-sizing: border-box; }
|
|
234
425
|
.bug {
|
|
235
|
-
position: fixed; z-index: 2147483000;
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
width: 44px; height: 44px; border-radius: 50%; padding: 0;
|
|
239
|
-
display: grid; place-items: center; cursor: pointer;
|
|
426
|
+
position: fixed; z-index: 2147483000; left: 0; top: 0;
|
|
427
|
+
width: 44px; height: 44px; border-radius: 22px; padding: 0; margin: 0;
|
|
428
|
+
display: var(--fs-feedback-display, grid); place-items: center; cursor: grab;
|
|
240
429
|
background: var(--surface); color: var(--ink); border: 1px solid var(--line);
|
|
241
|
-
box-shadow: 0
|
|
242
|
-
-webkit-tap-highlight-color: transparent;
|
|
430
|
+
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.2), 0 2px 5px rgba(0, 0, 0, 0.12);
|
|
431
|
+
-webkit-tap-highlight-color: transparent; touch-action: none; user-select: none; -webkit-user-select: none;
|
|
432
|
+
will-change: transform;
|
|
433
|
+
}
|
|
434
|
+
@supports ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
|
|
435
|
+
.bug {
|
|
436
|
+
background: color-mix(in srgb, var(--surface) 74%, transparent);
|
|
437
|
+
-webkit-backdrop-filter: blur(14px) saturate(160%); backdrop-filter: blur(14px) saturate(160%);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
.bug svg { width: 20px; height: 20px; pointer-events: none; }
|
|
441
|
+
.bug.down svg { transform: scale(0.9); }
|
|
442
|
+
.bug.dragging { cursor: grabbing; box-shadow: 0 18px 36px rgba(0, 0, 0, 0.26), 0 3px 8px rgba(0, 0, 0, 0.14); }
|
|
443
|
+
.bug[data-state^="dock"] { width: 26px; height: 54px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.14); opacity: 0.92; }
|
|
444
|
+
.bug[data-state="dock-right"] { border-radius: 16px 0 0 16px; border-right-width: 0; }
|
|
445
|
+
.bug[data-state="dock-left"] { border-radius: 0 16px 16px 0; border-left-width: 0; }
|
|
446
|
+
.bug[data-state^="dock"] svg { width: 16px; height: 16px; }
|
|
447
|
+
.probe {
|
|
448
|
+
position: fixed; visibility: hidden; pointer-events: none; width: 0; height: 0;
|
|
449
|
+
padding: env(safe-area-inset-top, 0px) 0 env(safe-area-inset-bottom, 0px) 0;
|
|
450
|
+
}
|
|
451
|
+
.tuck { align-self: center; font-size: 13px; color: var(--ink-dim); }
|
|
452
|
+
@media (prefers-reduced-transparency: reduce) {
|
|
453
|
+
.bug { background: var(--surface); -webkit-backdrop-filter: none; backdrop-filter: none; }
|
|
454
|
+
}
|
|
455
|
+
@media (prefers-contrast: more) {
|
|
456
|
+
.bug { background: var(--surface); border-color: var(--ink); -webkit-backdrop-filter: none; backdrop-filter: none; }
|
|
243
457
|
}
|
|
244
|
-
.bug svg { width: 20px; height: 20px; }
|
|
245
458
|
.bug:focus-visible, .send:focus-visible, .link:focus-visible, textarea:focus-visible {
|
|
246
459
|
outline: 2px solid var(--ink); outline-offset: 2px;
|
|
247
460
|
}
|
|
@@ -256,9 +469,15 @@ var STYLE = `
|
|
|
256
469
|
font-family: var(--font-body); font-size: 15px; line-height: 1.4;
|
|
257
470
|
}
|
|
258
471
|
.grab { width: 36px; height: 4px; border-radius: 2px; background: var(--line); align-self: center; }
|
|
259
|
-
h2 {
|
|
472
|
+
h2 {
|
|
473
|
+
margin: 2px 0 0; font-family: var(--font-display); font-weight: var(--display-weight);
|
|
474
|
+
font-size: 22px; line-height: 1.15; letter-spacing: var(--display-tracking); text-wrap: balance;
|
|
475
|
+
}
|
|
260
476
|
.shot { display: flex; gap: 12px; align-items: center; font-size: 13px; color: var(--ink-dim); }
|
|
261
477
|
.shot img { width: 50px; max-height: 108px; object-fit: cover; object-position: top; border-radius: 8px; border: 1px solid var(--line); }
|
|
478
|
+
.ghost { display: none; width: 50px; height: 108px; border-radius: 8px; border: 1px solid var(--line); background: var(--fill); }
|
|
479
|
+
.shot[data-pending] .ghost { display: block; }
|
|
480
|
+
.shot[data-pending] img { display: none; }
|
|
262
481
|
.link { background: none; border: 0; padding: 0; font: inherit; color: var(--ink); text-decoration: underline; text-underline-offset: 3px; cursor: pointer; }
|
|
263
482
|
textarea {
|
|
264
483
|
width: 100%; min-height: 84px; resize: none; font: inherit; font-size: 16px; color: var(--ink);
|
|
@@ -266,19 +485,29 @@ textarea {
|
|
|
266
485
|
}
|
|
267
486
|
textarea::placeholder { color: var(--ink-dim); }
|
|
268
487
|
.send {
|
|
269
|
-
font:
|
|
488
|
+
font-family: var(--font-button); font-weight: var(--button-weight); font-size: 17px; line-height: 1.2;
|
|
489
|
+
letter-spacing: var(--button-tracking); text-transform: var(--button-transform);
|
|
490
|
+
cursor: pointer; border: 0; padding: 15px 14px;
|
|
270
491
|
background: var(--cta); color: var(--cta-ink); border-radius: var(--button-radius);
|
|
271
492
|
}
|
|
272
493
|
.send[disabled] { opacity: 0.5; cursor: default; }
|
|
273
494
|
.status { margin: 0; font-size: 13px; color: var(--ink-dim); min-height: 1.4em; }
|
|
274
495
|
[hidden] { display: none !important; }
|
|
275
496
|
@media (prefers-reduced-motion: no-preference) {
|
|
497
|
+
.bug {
|
|
498
|
+
transition: width 0.22s cubic-bezier(0.2, 0.8, 0.2, 1), height 0.22s cubic-bezier(0.2, 0.8, 0.2, 1),
|
|
499
|
+
border-radius 0.22s cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 0.22s ease;
|
|
500
|
+
}
|
|
501
|
+
.bug svg { transition: transform 0.1s ease-out, width 0.22s ease, height 0.22s ease; }
|
|
276
502
|
.sheet { animation: rise 0.22s ease-out; }
|
|
503
|
+
.shot[data-pending] .ghost { animation: wait 1.1s ease-in-out infinite alternate; }
|
|
504
|
+
@keyframes wait { from { opacity: 0.45; } to { opacity: 1; } }
|
|
277
505
|
@keyframes rise { from { transform: translateY(24px); opacity: 0; } to { transform: none; opacity: 1; } }
|
|
278
506
|
}
|
|
279
507
|
`;
|
|
280
508
|
var mounted = null;
|
|
281
509
|
var feedbackOptedOut = (doc = document) => doc.documentElement.getAttribute("data-fs-feedback") === "off";
|
|
510
|
+
var SCROLL_OPTIONS = { capture: true, passive: true };
|
|
282
511
|
function mountFlowFeedback(options) {
|
|
283
512
|
if (typeof document === "undefined" || !document.body) return null;
|
|
284
513
|
if (mounted) return mounted;
|
|
@@ -287,15 +516,17 @@ function mountFlowFeedback(options) {
|
|
|
287
516
|
host.setAttribute(FEEDBACK_HOST_ATTR, "");
|
|
288
517
|
const root = host.attachShadow({ mode: "open" });
|
|
289
518
|
root.innerHTML = `<style>${STYLE}</style>
|
|
290
|
-
<button type="button" class="bug" aria-label="Send feedback on this page">${BUG_ICON}</button>
|
|
519
|
+
<button type="button" class="bug" data-state="float" aria-label="Send feedback on this page">${BUG_ICON}</button>
|
|
520
|
+
<div class="probe" aria-hidden="true"></div>
|
|
291
521
|
<div class="scrim" hidden></div>
|
|
292
522
|
<div class="sheet" role="dialog" aria-modal="true" aria-labelledby="fsf-title" hidden>
|
|
293
523
|
<div class="grab" aria-hidden="true"></div>
|
|
294
524
|
<h2 id="fsf-title">Feedback on this page</h2>
|
|
295
|
-
<div class="shot" hidden><img alt="Screenshot of this page" /><span><span>Screenshot attached</span><br /><button type="button" class="link remove">Remove screenshot</button></span></div>
|
|
525
|
+
<div class="shot" hidden><span class="ghost" aria-hidden="true"></span><img alt="Screenshot of this page" /><span><span class="shot-label">Screenshot attached</span><br /><button type="button" class="link remove">Remove screenshot</button></span></div>
|
|
296
526
|
<textarea aria-labelledby="fsf-title" placeholder="What should change?" maxlength="2000"></textarea>
|
|
297
527
|
<p class="status" role="status" aria-live="polite"></p>
|
|
298
528
|
<button type="button" class="send" disabled>Send feedback</button>
|
|
529
|
+
<button type="button" class="link tuck">Tuck the button to the side</button>
|
|
299
530
|
</div>`;
|
|
300
531
|
const q = (selector) => root.querySelector(selector);
|
|
301
532
|
const bug = q(".bug");
|
|
@@ -303,14 +534,204 @@ function mountFlowFeedback(options) {
|
|
|
303
534
|
const sheet = q(".sheet");
|
|
304
535
|
const shot = q(".shot");
|
|
305
536
|
const img = q(".shot img");
|
|
537
|
+
const shotLabel = q(".shot-label");
|
|
306
538
|
const remove = q(".remove");
|
|
307
539
|
const text = q("textarea");
|
|
308
540
|
const status = q(".status");
|
|
309
541
|
const send = q(".send");
|
|
542
|
+
const tuck = q(".tuck");
|
|
543
|
+
const probe = q(".probe");
|
|
310
544
|
let theme = readFlowTheme();
|
|
311
545
|
let screenshot = null;
|
|
312
546
|
let busy = false;
|
|
313
547
|
let closeTimer = null;
|
|
548
|
+
let placeTimer = null;
|
|
549
|
+
let autoBottom = PLACEMENT_BASE;
|
|
550
|
+
let spot = { state: "float", x: 0, y: 0 };
|
|
551
|
+
let placedByPerson = false;
|
|
552
|
+
let velocity = { x: 0, y: 0 };
|
|
553
|
+
let frame = 0;
|
|
554
|
+
const px = (name) => {
|
|
555
|
+
const raw = getComputedStyle(host).getPropertyValue(name).trim();
|
|
556
|
+
const n = raw ? parseFloat(raw) : NaN;
|
|
557
|
+
return Number.isFinite(n) ? n : null;
|
|
558
|
+
};
|
|
559
|
+
const viewport = () => {
|
|
560
|
+
const style = getComputedStyle(probe);
|
|
561
|
+
return {
|
|
562
|
+
width: window.innerWidth,
|
|
563
|
+
height: window.innerHeight,
|
|
564
|
+
top: parseFloat(style.paddingTop) || 0,
|
|
565
|
+
bottom: parseFloat(style.paddingBottom) || 0
|
|
566
|
+
};
|
|
567
|
+
};
|
|
568
|
+
const draw = () => {
|
|
569
|
+
bug.style.transform = `translate3d(${spot.x}px, ${spot.y}px, 0)`;
|
|
570
|
+
};
|
|
571
|
+
const setState = (next) => {
|
|
572
|
+
spot.state = next;
|
|
573
|
+
bug.dataset.state = next;
|
|
574
|
+
};
|
|
575
|
+
const remember = (spot2) => {
|
|
576
|
+
if (!placedByPerson) return;
|
|
577
|
+
try {
|
|
578
|
+
const view = viewport();
|
|
579
|
+
const width = spot2.state === "float" ? BUTTON_SIZE : TAB_WIDTH;
|
|
580
|
+
localStorage.setItem(
|
|
581
|
+
SPOT_KEY,
|
|
582
|
+
JSON.stringify({
|
|
583
|
+
state: spot2.state,
|
|
584
|
+
left: spot2.x + width / 2 < view.width / 2,
|
|
585
|
+
y: view.height > 0 ? spot2.y / view.height : 0
|
|
586
|
+
})
|
|
587
|
+
);
|
|
588
|
+
} catch {
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
const still = () => typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
592
|
+
const springTo = (to, flicked) => {
|
|
593
|
+
cancelAnimationFrame(frame);
|
|
594
|
+
frame = 0;
|
|
595
|
+
setState(to.state);
|
|
596
|
+
remember(to);
|
|
597
|
+
if (still() || typeof requestAnimationFrame !== "function") {
|
|
598
|
+
spot = { ...to };
|
|
599
|
+
velocity = { x: 0, y: 0 };
|
|
600
|
+
draw();
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
const omega = 2 * Math.PI / 0.4;
|
|
604
|
+
const k = omega * omega;
|
|
605
|
+
const c = 2 * (flicked ? 0.8 : 1) * omega;
|
|
606
|
+
let last = performance.now();
|
|
607
|
+
const tick = (now) => {
|
|
608
|
+
const dt = Math.min(0.032, (now - last) / 1e3);
|
|
609
|
+
last = now;
|
|
610
|
+
velocity.x += (-k * (spot.x - to.x) - c * velocity.x) * dt;
|
|
611
|
+
velocity.y += (-k * (spot.y - to.y) - c * velocity.y) * dt;
|
|
612
|
+
spot.x += velocity.x * dt;
|
|
613
|
+
spot.y += velocity.y * dt;
|
|
614
|
+
const close2 = Math.abs(spot.x - to.x) < 0.3 && Math.abs(spot.y - to.y) < 0.3 && Math.hypot(velocity.x, velocity.y) < 6;
|
|
615
|
+
if (close2) {
|
|
616
|
+
spot = { ...to };
|
|
617
|
+
velocity = { x: 0, y: 0 };
|
|
618
|
+
frame = 0;
|
|
619
|
+
draw();
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
draw();
|
|
623
|
+
frame = requestAnimationFrame(tick);
|
|
624
|
+
};
|
|
625
|
+
frame = requestAnimationFrame(tick);
|
|
626
|
+
};
|
|
627
|
+
const place = () => {
|
|
628
|
+
placeTimer = null;
|
|
629
|
+
if (!sheet.hidden || bug.hidden || drag || frame) return;
|
|
630
|
+
const view = viewport();
|
|
631
|
+
if (placedByPerson || spot.state !== "float") {
|
|
632
|
+
const width = spot.state === "float" ? BUTTON_SIZE : TAB_WIDTH;
|
|
633
|
+
spot = restingSpot(spot.state, spot.x + width / 2 < view.width / 2, spot.y, view);
|
|
634
|
+
draw();
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
const right = px("--fs-feedback-right") ?? 14;
|
|
638
|
+
const left = view.width - right - BUTTON_SIZE;
|
|
639
|
+
const pinned = px("--fs-feedback-bottom");
|
|
640
|
+
if (pinned === null && typeof document.elementsFromPoint === "function") {
|
|
641
|
+
autoBottom = choosePlacement(
|
|
642
|
+
{
|
|
643
|
+
left,
|
|
644
|
+
top: view.height - view.bottom - autoBottom - BUTTON_SIZE,
|
|
645
|
+
size: BUTTON_SIZE,
|
|
646
|
+
bottom: autoBottom
|
|
647
|
+
},
|
|
648
|
+
view.height,
|
|
649
|
+
occupancyReader(host)
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
const bottom = pinned ?? autoBottom;
|
|
653
|
+
spot = { state: "float", x: left, y: view.height - view.bottom - bottom - BUTTON_SIZE };
|
|
654
|
+
draw();
|
|
655
|
+
};
|
|
656
|
+
const schedulePlace = () => {
|
|
657
|
+
if (placeTimer) clearTimeout(placeTimer);
|
|
658
|
+
placeTimer = setTimeout(place, 150);
|
|
659
|
+
};
|
|
660
|
+
let drag = null;
|
|
661
|
+
let suppressClick = false;
|
|
662
|
+
const onDown = (event) => {
|
|
663
|
+
if (!sheet.hidden || busy) return;
|
|
664
|
+
bug.setPointerCapture?.(event.pointerId);
|
|
665
|
+
cancelAnimationFrame(frame);
|
|
666
|
+
frame = 0;
|
|
667
|
+
velocity = { x: 0, y: 0 };
|
|
668
|
+
drag = {
|
|
669
|
+
id: event.pointerId,
|
|
670
|
+
ox: event.clientX - spot.x,
|
|
671
|
+
oy: event.clientY - spot.y,
|
|
672
|
+
sx: event.clientX,
|
|
673
|
+
sy: event.clientY,
|
|
674
|
+
moved: false,
|
|
675
|
+
history: [{ t: event.timeStamp, x: event.clientX, y: event.clientY }]
|
|
676
|
+
};
|
|
677
|
+
bug.classList.add("down");
|
|
678
|
+
};
|
|
679
|
+
const onMove = (event) => {
|
|
680
|
+
if (!drag || event.pointerId !== drag.id) return;
|
|
681
|
+
const dx = event.clientX - drag.sx;
|
|
682
|
+
const dy = event.clientY - drag.sy;
|
|
683
|
+
if (!drag.moved && Math.hypot(dx, dy) < 8) return;
|
|
684
|
+
if (!drag.moved) {
|
|
685
|
+
drag.moved = true;
|
|
686
|
+
bug.classList.add("dragging");
|
|
687
|
+
}
|
|
688
|
+
drag.history.push({ t: event.timeStamp, x: event.clientX, y: event.clientY });
|
|
689
|
+
if (drag.history.length > 6) drag.history.shift();
|
|
690
|
+
const view = viewport();
|
|
691
|
+
if (spot.state !== "float") {
|
|
692
|
+
const inward = spot.state === "dock-left" ? dx : -dx;
|
|
693
|
+
if (inward <= 18) {
|
|
694
|
+
spot = restingSpot(spot.state, spot.state === "dock-left", event.clientY - drag.oy, view);
|
|
695
|
+
draw();
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
setState("float");
|
|
699
|
+
drag.ox = BUTTON_SIZE / 2;
|
|
700
|
+
drag.oy = BUTTON_SIZE / 2;
|
|
701
|
+
}
|
|
702
|
+
let x = event.clientX - drag.ox;
|
|
703
|
+
let y = event.clientY - drag.oy;
|
|
704
|
+
const maxX = view.width - BUTTON_SIZE;
|
|
705
|
+
const maxY = view.height - BUTTON_SIZE;
|
|
706
|
+
if (x < 0) x = rubberband(x, 60);
|
|
707
|
+
else if (x > maxX) x = maxX + rubberband(x - maxX, 60);
|
|
708
|
+
if (y < 0) y = rubberband(y, 80);
|
|
709
|
+
else if (y > maxY) y = maxY + rubberband(y - maxY, 80);
|
|
710
|
+
spot.x = x;
|
|
711
|
+
spot.y = y;
|
|
712
|
+
draw();
|
|
713
|
+
};
|
|
714
|
+
const onUp = (event) => {
|
|
715
|
+
if (!drag || event.pointerId !== drag.id) return;
|
|
716
|
+
const ended = drag;
|
|
717
|
+
drag = null;
|
|
718
|
+
bug.classList.remove("down", "dragging");
|
|
719
|
+
if (!ended.moved) return;
|
|
720
|
+
suppressClick = true;
|
|
721
|
+
setTimeout(() => {
|
|
722
|
+
suppressClick = false;
|
|
723
|
+
}, 0);
|
|
724
|
+
const first = ended.history[0];
|
|
725
|
+
const last = ended.history[ended.history.length - 1];
|
|
726
|
+
const dt = Math.max(1, last.t - first.t);
|
|
727
|
+
const paused = event.timeStamp - last.t > 90;
|
|
728
|
+
velocity = paused ? { x: 0, y: 0 } : { x: (last.x - first.x) / dt * 1e3, y: (last.y - first.y) / dt * 1e3 };
|
|
729
|
+
placedByPerson = true;
|
|
730
|
+
springTo(settleRelease(spot, velocity, viewport()), Math.hypot(velocity.x, velocity.y) > 350);
|
|
731
|
+
};
|
|
732
|
+
const observer = typeof MutationObserver === "function" ? new MutationObserver((records) => {
|
|
733
|
+
if (records.some((r) => r.target !== host)) schedulePlace();
|
|
734
|
+
}) : null;
|
|
314
735
|
const paint = () => {
|
|
315
736
|
theme = readFlowTheme();
|
|
316
737
|
const vars = {
|
|
@@ -324,6 +745,11 @@ function mountFlowFeedback(options) {
|
|
|
324
745
|
"--font-body": theme.fontBody,
|
|
325
746
|
"--font-display": theme.fontDisplay,
|
|
326
747
|
"--display-weight": theme.displayWeight,
|
|
748
|
+
"--display-tracking": theme.displayTracking,
|
|
749
|
+
"--font-button": theme.fontButton,
|
|
750
|
+
"--button-weight": theme.buttonWeight,
|
|
751
|
+
"--button-tracking": theme.buttonTracking,
|
|
752
|
+
"--button-transform": theme.buttonTransform,
|
|
327
753
|
"--radius": theme.radius,
|
|
328
754
|
"--button-radius": theme.buttonRadius
|
|
329
755
|
};
|
|
@@ -333,15 +759,21 @@ function mountFlowFeedback(options) {
|
|
|
333
759
|
const sync = () => {
|
|
334
760
|
send.disabled = busy || text.value.trim().length === 0;
|
|
335
761
|
};
|
|
762
|
+
let round = 0;
|
|
763
|
+
let pending = null;
|
|
336
764
|
const setShot = (url) => {
|
|
337
765
|
screenshot = url;
|
|
338
766
|
shot.hidden = url === null;
|
|
767
|
+
shot.removeAttribute("data-pending");
|
|
768
|
+
shotLabel.textContent = "Screenshot attached";
|
|
339
769
|
if (url) img.src = url;
|
|
340
770
|
else img.removeAttribute("src");
|
|
341
771
|
};
|
|
342
772
|
const close = () => {
|
|
343
773
|
if (closeTimer) clearTimeout(closeTimer);
|
|
344
774
|
closeTimer = null;
|
|
775
|
+
round += 1;
|
|
776
|
+
pending = null;
|
|
345
777
|
sheet.hidden = true;
|
|
346
778
|
scrim.hidden = true;
|
|
347
779
|
bug.hidden = false;
|
|
@@ -350,20 +782,30 @@ function mountFlowFeedback(options) {
|
|
|
350
782
|
status.textContent = "";
|
|
351
783
|
busy = false;
|
|
352
784
|
sync();
|
|
785
|
+
schedulePlace();
|
|
353
786
|
};
|
|
354
|
-
const open =
|
|
355
|
-
if (!sheet.hidden
|
|
356
|
-
busy
|
|
787
|
+
const open = () => {
|
|
788
|
+
if (!sheet.hidden) return pending ?? Promise.resolve();
|
|
789
|
+
if (busy) return Promise.resolve();
|
|
357
790
|
paint();
|
|
791
|
+
const mine = round += 1;
|
|
358
792
|
bug.hidden = true;
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
793
|
+
setShot(null);
|
|
794
|
+
shot.hidden = false;
|
|
795
|
+
shot.setAttribute("data-pending", "");
|
|
796
|
+
shotLabel.textContent = "Taking a screenshot";
|
|
797
|
+
tuck.hidden = spot.state !== "float";
|
|
363
798
|
scrim.hidden = false;
|
|
364
799
|
sheet.hidden = false;
|
|
365
800
|
sync();
|
|
366
|
-
|
|
801
|
+
const capture = options.capture ?? (() => captureViewport(theme.ground));
|
|
802
|
+
pending = withTimeout(Promise.resolve().then(capture), CAPTURE_TIMEOUT_MS).then((url) => {
|
|
803
|
+
if (mine !== round) return;
|
|
804
|
+
pending = null;
|
|
805
|
+
setShot(url);
|
|
806
|
+
if (!root.activeElement || root.activeElement === bug) text.focus();
|
|
807
|
+
});
|
|
808
|
+
return pending;
|
|
367
809
|
};
|
|
368
810
|
const submit = async () => {
|
|
369
811
|
const message = text.value.trim();
|
|
@@ -371,6 +813,9 @@ function mountFlowFeedback(options) {
|
|
|
371
813
|
busy = true;
|
|
372
814
|
sync();
|
|
373
815
|
status.textContent = "";
|
|
816
|
+
const mine = round;
|
|
817
|
+
if (pending) await pending;
|
|
818
|
+
if (mine !== round) return;
|
|
374
819
|
try {
|
|
375
820
|
await options.submit({
|
|
376
821
|
message,
|
|
@@ -390,9 +835,39 @@ function mountFlowFeedback(options) {
|
|
|
390
835
|
sync();
|
|
391
836
|
}
|
|
392
837
|
};
|
|
393
|
-
bug.addEventListener("click", () =>
|
|
838
|
+
bug.addEventListener("click", () => {
|
|
839
|
+
if (!suppressClick) void open();
|
|
840
|
+
});
|
|
841
|
+
bug.addEventListener("pointerdown", onDown);
|
|
842
|
+
if (!options.capture)
|
|
843
|
+
bug.addEventListener("pointerdown", () => void import("modern-screenshot").catch(() => {
|
|
844
|
+
}), {
|
|
845
|
+
once: true
|
|
846
|
+
});
|
|
847
|
+
bug.addEventListener("pointermove", onMove);
|
|
848
|
+
bug.addEventListener("pointerup", onUp);
|
|
849
|
+
bug.addEventListener("pointercancel", onUp);
|
|
850
|
+
tuck.addEventListener("click", () => {
|
|
851
|
+
const view = viewport();
|
|
852
|
+
const left = spot.x + BUTTON_SIZE / 2 < view.width / 2;
|
|
853
|
+
close();
|
|
854
|
+
placedByPerson = true;
|
|
855
|
+
springTo(
|
|
856
|
+
restingSpot(
|
|
857
|
+
left ? "dock-left" : "dock-right",
|
|
858
|
+
left,
|
|
859
|
+
spot.y + (BUTTON_SIZE - TAB_HEIGHT) / 2,
|
|
860
|
+
view
|
|
861
|
+
),
|
|
862
|
+
false
|
|
863
|
+
);
|
|
864
|
+
});
|
|
394
865
|
scrim.addEventListener("click", close);
|
|
395
|
-
remove.addEventListener("click", () =>
|
|
866
|
+
remove.addEventListener("click", () => {
|
|
867
|
+
round += 1;
|
|
868
|
+
pending = null;
|
|
869
|
+
setShot(null);
|
|
870
|
+
});
|
|
396
871
|
text.addEventListener("input", sync);
|
|
397
872
|
send.addEventListener("click", () => void submit());
|
|
398
873
|
sheet.addEventListener("keydown", (event) => {
|
|
@@ -404,6 +879,29 @@ function mountFlowFeedback(options) {
|
|
|
404
879
|
});
|
|
405
880
|
document.body.appendChild(host);
|
|
406
881
|
paint();
|
|
882
|
+
try {
|
|
883
|
+
const saved = JSON.parse(localStorage.getItem(SPOT_KEY) ?? "null");
|
|
884
|
+
if (saved && typeof saved.y === "number" && (saved.state === "float" || saved.state === "dock-left" || saved.state === "dock-right")) {
|
|
885
|
+
const view = viewport();
|
|
886
|
+
placedByPerson = true;
|
|
887
|
+
setState(saved.state);
|
|
888
|
+
spot = restingSpot(saved.state, saved.left === true, saved.y * view.height, view);
|
|
889
|
+
}
|
|
890
|
+
} catch {
|
|
891
|
+
}
|
|
892
|
+
place();
|
|
893
|
+
schedulePlace();
|
|
894
|
+
observer?.observe(document.body, {
|
|
895
|
+
childList: true,
|
|
896
|
+
subtree: true,
|
|
897
|
+
attributes: true,
|
|
898
|
+
attributeFilter: ["class", "style", "hidden"]
|
|
899
|
+
});
|
|
900
|
+
window.addEventListener("resize", schedulePlace);
|
|
901
|
+
window.addEventListener("load", schedulePlace);
|
|
902
|
+
window.addEventListener("scroll", schedulePlace, SCROLL_OPTIONS);
|
|
903
|
+
document.addEventListener("transitionend", schedulePlace, SCROLL_OPTIONS);
|
|
904
|
+
document.addEventListener("animationend", schedulePlace, SCROLL_OPTIONS);
|
|
407
905
|
options.onReady?.();
|
|
408
906
|
mounted = {
|
|
409
907
|
host,
|
|
@@ -414,6 +912,14 @@ function mountFlowFeedback(options) {
|
|
|
414
912
|
close,
|
|
415
913
|
unmount: () => {
|
|
416
914
|
if (closeTimer) clearTimeout(closeTimer);
|
|
915
|
+
if (placeTimer) clearTimeout(placeTimer);
|
|
916
|
+
cancelAnimationFrame(frame);
|
|
917
|
+
observer?.disconnect();
|
|
918
|
+
window.removeEventListener("resize", schedulePlace);
|
|
919
|
+
window.removeEventListener("load", schedulePlace);
|
|
920
|
+
window.removeEventListener("scroll", schedulePlace, SCROLL_OPTIONS);
|
|
921
|
+
document.removeEventListener("transitionend", schedulePlace, SCROLL_OPTIONS);
|
|
922
|
+
document.removeEventListener("animationend", schedulePlace, SCROLL_OPTIONS);
|
|
417
923
|
host.remove();
|
|
418
924
|
mounted = null;
|
|
419
925
|
}
|