@syntrologie/adapt-faq 2.27.0 → 2.29.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/FAQWidgetLit.d.ts +147 -7
- package/dist/FAQWidgetLit.d.ts.map +1 -1
- package/dist/cdn.d.ts +56 -0
- package/dist/cdn.d.ts.map +1 -0
- package/dist/cdn.js +35 -0
- package/dist/cdn.js.map +7 -0
- package/dist/{chunk-5WRI5ZAA.js → chunk-7DTOSQNC.js} +14 -2
- package/dist/chunk-AJELMQH5.js +1671 -0
- package/dist/chunk-AJELMQH5.js.map +7 -0
- package/dist/{chunk-RYRNJ2UU.js → chunk-YNDAVOD7.js} +55 -7
- package/dist/chunk-YNDAVOD7.js.map +7 -0
- package/dist/editor.js +620 -1457
- package/dist/editor.js.map +3 -3
- package/dist/faq-styles.d.ts +66 -1
- package/dist/faq-styles.d.ts.map +1 -1
- package/dist/renderHealth.d.ts +81 -0
- package/dist/renderHealth.d.ts.map +1 -0
- package/dist/runtime.js +7 -834
- package/dist/runtime.js.map +4 -4
- package/dist/schema.d.ts +346 -227
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +14 -7
- package/dist/schema.js.map +2 -2
- package/dist/types.d.ts +17 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +8 -1
- package/dist/chunk-RYRNJ2UU.js.map +0 -7
- /package/dist/{chunk-5WRI5ZAA.js.map → chunk-7DTOSQNC.js.map} +0 -0
|
@@ -0,0 +1,1671 @@
|
|
|
1
|
+
import {
|
|
2
|
+
dispatchDiveDeeper,
|
|
3
|
+
stripMountPlumbing
|
|
4
|
+
} from "./chunk-YNDAVOD7.js";
|
|
5
|
+
import {
|
|
6
|
+
getAnswerText,
|
|
7
|
+
purple,
|
|
8
|
+
renderAnswerHtml,
|
|
9
|
+
slateGrey
|
|
10
|
+
} from "./chunk-KRKRB4OL.js";
|
|
11
|
+
import {
|
|
12
|
+
__privateAdd,
|
|
13
|
+
__privateGet,
|
|
14
|
+
__privateMethod,
|
|
15
|
+
__privateSet
|
|
16
|
+
} from "./chunk-7DTOSQNC.js";
|
|
17
|
+
|
|
18
|
+
// src/executors.ts
|
|
19
|
+
function resolveItem(store, itemId, itemQuestion) {
|
|
20
|
+
if (itemId) {
|
|
21
|
+
const found = store.getState().items.find((i) => i.config.id === itemId);
|
|
22
|
+
if (found) return found;
|
|
23
|
+
}
|
|
24
|
+
if (itemQuestion) {
|
|
25
|
+
const found = store.findByQuestion(itemQuestion);
|
|
26
|
+
if (found) return found;
|
|
27
|
+
}
|
|
28
|
+
throw new Error("FAQ item not found");
|
|
29
|
+
}
|
|
30
|
+
async function executeScrollToFaq(action, context, store) {
|
|
31
|
+
const item = resolveItem(store, action.itemId, action.itemQuestion);
|
|
32
|
+
const { id } = item.config;
|
|
33
|
+
if (action.expand !== false) {
|
|
34
|
+
store.expand(id);
|
|
35
|
+
}
|
|
36
|
+
const el = document.querySelector(`[data-faq-item-id="${id}"]`);
|
|
37
|
+
if (el) {
|
|
38
|
+
el.scrollIntoView({
|
|
39
|
+
behavior: action.behavior ?? "smooth"
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
context.publishEvent("faq:scroll_to", { itemId: id });
|
|
43
|
+
return {
|
|
44
|
+
cleanup: () => {
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
async function executeToggleFaqItem(action, context, store) {
|
|
49
|
+
const item = resolveItem(store, action.itemId, action.itemQuestion);
|
|
50
|
+
const { id } = item.config;
|
|
51
|
+
const desiredState = action.state ?? "toggle";
|
|
52
|
+
let newState;
|
|
53
|
+
switch (desiredState) {
|
|
54
|
+
case "open":
|
|
55
|
+
store.expand(id);
|
|
56
|
+
newState = "open";
|
|
57
|
+
break;
|
|
58
|
+
case "closed":
|
|
59
|
+
store.collapse(id);
|
|
60
|
+
newState = "closed";
|
|
61
|
+
break;
|
|
62
|
+
default: {
|
|
63
|
+
const wasExpanded = store.getState().expandedItems.has(id);
|
|
64
|
+
store.toggle(id);
|
|
65
|
+
newState = wasExpanded ? "closed" : "open";
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
context.publishEvent("faq:toggle", { itemId: id, newState });
|
|
70
|
+
return {
|
|
71
|
+
cleanup: () => {
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
async function executeUpdateFaq(action, context, store) {
|
|
76
|
+
switch (action.operation) {
|
|
77
|
+
case "add": {
|
|
78
|
+
const items = action.items ?? [];
|
|
79
|
+
const position = action.position === "prepend" ? "prepend" : "append";
|
|
80
|
+
store.addItems(items, position);
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
case "remove": {
|
|
84
|
+
if (!action.itemId) {
|
|
85
|
+
throw new Error("FAQ item not found");
|
|
86
|
+
}
|
|
87
|
+
const exists = store.getState().items.some((i) => i.config.id === action.itemId);
|
|
88
|
+
if (!exists) {
|
|
89
|
+
throw new Error("FAQ item not found");
|
|
90
|
+
}
|
|
91
|
+
store.removeItem(action.itemId);
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
case "reorder": {
|
|
95
|
+
const order = action.order ?? [];
|
|
96
|
+
store.reorderItems(order);
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
case "replace": {
|
|
100
|
+
const items = action.items ?? [];
|
|
101
|
+
store.replaceItems(items);
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
context.publishEvent("faq:update", { operation: action.operation });
|
|
106
|
+
return {
|
|
107
|
+
cleanup: () => {
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
var executorDefinitions = [
|
|
112
|
+
{ kind: "faq:scroll_to", executor: executeScrollToFaq },
|
|
113
|
+
{ kind: "faq:toggle_item", executor: executeToggleFaqItem },
|
|
114
|
+
{ kind: "faq:update", executor: executeUpdateFaq }
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
// ../../canvas-sdk/dist/flip.js
|
|
118
|
+
var FLIP_TRANSITION_MS = 360;
|
|
119
|
+
var FLIP_CLEAR_BUFFER_MS = 120;
|
|
120
|
+
function prefersReducedMotion() {
|
|
121
|
+
if (typeof window === "undefined")
|
|
122
|
+
return false;
|
|
123
|
+
return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
|
|
124
|
+
}
|
|
125
|
+
function flipStyles(scope, opts) {
|
|
126
|
+
const f = opts.ns;
|
|
127
|
+
const kf = opts.keyframePrefix;
|
|
128
|
+
return `
|
|
129
|
+
${scope} [data-${f}-flip] {
|
|
130
|
+
position: relative;
|
|
131
|
+
}
|
|
132
|
+
/* --- 3D machinery: ONLY while the flip is animating --- */
|
|
133
|
+
/* Perspective (the composited GPU context) lives on the parent viewport,
|
|
134
|
+
gated to the animating window so the RESTING open face never sits on a
|
|
135
|
+
perspective layer (GPU/compositing hazard: rotateY+perspective at rest
|
|
136
|
+
blanks text on some Android GPUs, rasterizes soft everywhere). */
|
|
137
|
+
${scope} [data-${f}-flip-viewport][data-${f}-flip-animating] {
|
|
138
|
+
perspective: 1000px;
|
|
139
|
+
}
|
|
140
|
+
${scope} [data-${f}-flip][data-${f}-flip-animating] {
|
|
141
|
+
transform-style: preserve-3d;
|
|
142
|
+
}
|
|
143
|
+
${scope} [data-${f}-flip][data-${f}-flip-animating] > [data-face] {
|
|
144
|
+
backface-visibility: hidden;
|
|
145
|
+
-webkit-backface-visibility: hidden;
|
|
146
|
+
}
|
|
147
|
+
${scope} [data-${f}-flip][data-${f}-flip-animating] > [data-face="front"] {
|
|
148
|
+
position: relative;
|
|
149
|
+
}
|
|
150
|
+
${scope} [data-${f}-flip][data-${f}-flip-animating] > [data-face="back"] {
|
|
151
|
+
position: absolute;
|
|
152
|
+
inset: 0;
|
|
153
|
+
transform: rotateY(180deg);
|
|
154
|
+
}
|
|
155
|
+
${scope} [data-${f}-flip][data-${f}-flip-animating][data-${f}-flip-dir="in"] {
|
|
156
|
+
animation: ${kf}-in var(--vc-transition-duration, 240ms)
|
|
157
|
+
var(--vc-transition-easing, ease-out) forwards;
|
|
158
|
+
}
|
|
159
|
+
${scope} [data-${f}-flip][data-${f}-flip-animating][data-${f}-flip-dir="out"] {
|
|
160
|
+
animation: ${kf}-out var(--vc-transition-duration, 240ms)
|
|
161
|
+
var(--vc-transition-easing, ease-out) forwards;
|
|
162
|
+
}
|
|
163
|
+
@keyframes ${kf}-in {
|
|
164
|
+
from { transform: perspective(1000px) rotateY(0deg); }
|
|
165
|
+
to { transform: perspective(1000px) rotateY(180deg); }
|
|
166
|
+
}
|
|
167
|
+
@keyframes ${kf}-out {
|
|
168
|
+
from { transform: perspective(1000px) rotateY(180deg); }
|
|
169
|
+
to { transform: perspective(1000px) rotateY(0deg); }
|
|
170
|
+
}
|
|
171
|
+
/* --- RESTING state: the active face is FLAT, the inactive face is gone --- */
|
|
172
|
+
${scope} [data-${f}-flip]:not([data-${f}-flip-animating]) {
|
|
173
|
+
transform: none;
|
|
174
|
+
}
|
|
175
|
+
${scope} [data-${f}-flip]:not([data-${f}-flip-animating]) > [data-face] {
|
|
176
|
+
position: relative;
|
|
177
|
+
transform: none;
|
|
178
|
+
}
|
|
179
|
+
${scope} [data-${f}-flip]:not([data-${f}-flip-animating])[data-${f}-active-face="front"] > [data-face="back"] {
|
|
180
|
+
display: none;
|
|
181
|
+
}
|
|
182
|
+
${scope} [data-${f}-flip]:not([data-${f}-flip-animating])[data-${f}-active-face="back"] > [data-face="front"] {
|
|
183
|
+
display: none;
|
|
184
|
+
}
|
|
185
|
+
@media (prefers-reduced-motion: reduce) {
|
|
186
|
+
${scope} [data-${f}-flip] { animation: none; }
|
|
187
|
+
}
|
|
188
|
+
`;
|
|
189
|
+
}
|
|
190
|
+
var FlipController = class {
|
|
191
|
+
constructor(host, opts) {
|
|
192
|
+
this.openId = null;
|
|
193
|
+
this.activeFace = "front";
|
|
194
|
+
this.animating = false;
|
|
195
|
+
this.dir = "in";
|
|
196
|
+
this.settleTimer = null;
|
|
197
|
+
this.onAnimationEnd = (e) => {
|
|
198
|
+
if (e.animationName === `${this.keyframePrefix}-in` || e.animationName === `${this.keyframePrefix}-out`) {
|
|
199
|
+
this.settle();
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
this.settle = () => {
|
|
203
|
+
this.clearTimer();
|
|
204
|
+
this.animating = false;
|
|
205
|
+
if (this.activeFace === "front") {
|
|
206
|
+
this.openId = null;
|
|
207
|
+
}
|
|
208
|
+
this.host.requestUpdate();
|
|
209
|
+
};
|
|
210
|
+
this.host = host;
|
|
211
|
+
this.keyframePrefix = opts.keyframePrefix;
|
|
212
|
+
this.fallbackMs = opts.fallbackMs ?? FLIP_TRANSITION_MS;
|
|
213
|
+
this.clearBufferMs = opts.clearBufferMs ?? FLIP_CLEAR_BUFFER_MS;
|
|
214
|
+
host.addController(this);
|
|
215
|
+
}
|
|
216
|
+
hostDisconnected() {
|
|
217
|
+
this.clearTimer();
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Open `id` to the DETAIL (back) face and flip to it. A re-open before a
|
|
221
|
+
* previous flip settled clears the pending settle and restarts cleanly. Under
|
|
222
|
+
* reduced motion, settles straight to the flat back face (no animation).
|
|
223
|
+
*/
|
|
224
|
+
openTo(id) {
|
|
225
|
+
this.clearTimer();
|
|
226
|
+
this.openId = id;
|
|
227
|
+
this.activeFace = "back";
|
|
228
|
+
if (prefersReducedMotion()) {
|
|
229
|
+
this.animating = false;
|
|
230
|
+
this.host.requestUpdate();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
this.dir = "in";
|
|
234
|
+
this.animating = true;
|
|
235
|
+
this.settleTimer = setTimeout(this.settle, this.durationMs());
|
|
236
|
+
this.host.requestUpdate();
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Flip from the DETAIL (back) face back to the LIST (front) face. With motion,
|
|
240
|
+
* `openId` is kept through the rotation (back face stays populated, never
|
|
241
|
+
* blank mid-flip) and cleared on settle; under reduced motion it clears
|
|
242
|
+
* synchronously.
|
|
243
|
+
*/
|
|
244
|
+
back() {
|
|
245
|
+
this.activeFace = "front";
|
|
246
|
+
this.clearTimer();
|
|
247
|
+
if (prefersReducedMotion()) {
|
|
248
|
+
this.animating = false;
|
|
249
|
+
this.openId = null;
|
|
250
|
+
} else {
|
|
251
|
+
this.dir = "out";
|
|
252
|
+
this.animating = true;
|
|
253
|
+
this.settleTimer = setTimeout(this.settle, this.durationMs());
|
|
254
|
+
}
|
|
255
|
+
this.host.requestUpdate();
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Hard reset to the front face — the open item is gone (removed / gated out),
|
|
259
|
+
* so there's nothing to keep mounted through a flip-out. Clears the timer and
|
|
260
|
+
* settles synchronously.
|
|
261
|
+
*/
|
|
262
|
+
reset() {
|
|
263
|
+
this.clearTimer();
|
|
264
|
+
this.animating = false;
|
|
265
|
+
this.openId = null;
|
|
266
|
+
this.activeFace = "front";
|
|
267
|
+
this.host.requestUpdate();
|
|
268
|
+
}
|
|
269
|
+
clearTimer() {
|
|
270
|
+
if (this.settleTimer !== null) {
|
|
271
|
+
clearTimeout(this.settleTimer);
|
|
272
|
+
this.settleTimer = null;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* The flip transition duration in ms, coupled to the CSS. Reads the computed
|
|
277
|
+
* `--vc-transition-duration` token off the host so the JS tear-down timer is
|
|
278
|
+
* ALWAYS `>=` the CSS transition — a host that sets the token slower than our
|
|
279
|
+
* fallback no longer blanks the back face before the flip finishes. Falls
|
|
280
|
+
* back to `fallbackMs` when the token is absent/unreadable/malformed.
|
|
281
|
+
*/
|
|
282
|
+
durationMs() {
|
|
283
|
+
let tokenMs = 0;
|
|
284
|
+
if (typeof window !== "undefined" && typeof window.getComputedStyle === "function") {
|
|
285
|
+
try {
|
|
286
|
+
const raw = window.getComputedStyle(this.host).getPropertyValue("--vc-transition-duration").trim();
|
|
287
|
+
if (raw.endsWith("ms"))
|
|
288
|
+
tokenMs = Number.parseFloat(raw);
|
|
289
|
+
else if (raw.endsWith("s"))
|
|
290
|
+
tokenMs = Number.parseFloat(raw) * 1e3;
|
|
291
|
+
} catch {
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (!Number.isFinite(tokenMs))
|
|
295
|
+
tokenMs = 0;
|
|
296
|
+
return Math.max(tokenMs, this.fallbackMs) + (tokenMs > 0 ? this.clearBufferMs : 0);
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
// src/FAQWidgetLit.ts
|
|
301
|
+
import { html, LitElement, nothing } from "lit";
|
|
302
|
+
import { styleMap } from "lit/directives/style-map.js";
|
|
303
|
+
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
|
304
|
+
|
|
305
|
+
// src/faq-styles.ts
|
|
306
|
+
var baseStyles = {
|
|
307
|
+
container: {
|
|
308
|
+
fontFamily: "var(--sc-font-family, system-ui, -apple-system, sans-serif)",
|
|
309
|
+
maxWidth: "800px",
|
|
310
|
+
margin: "0 auto"
|
|
311
|
+
},
|
|
312
|
+
searchWrapper: {
|
|
313
|
+
marginBottom: "8px"
|
|
314
|
+
},
|
|
315
|
+
searchInput: {
|
|
316
|
+
width: "100%",
|
|
317
|
+
padding: "12px 16px",
|
|
318
|
+
borderRadius: "8px",
|
|
319
|
+
fontSize: "14px",
|
|
320
|
+
outline: "none",
|
|
321
|
+
transition: "border-color 0.15s ease",
|
|
322
|
+
backgroundColor: "var(--sc-content-search-background)",
|
|
323
|
+
color: "var(--sc-content-search-color)"
|
|
324
|
+
},
|
|
325
|
+
accordion: {
|
|
326
|
+
display: "flex",
|
|
327
|
+
flexDirection: "column",
|
|
328
|
+
gap: "var(--sc-content-item-gap, 6px)"
|
|
329
|
+
},
|
|
330
|
+
item: {
|
|
331
|
+
borderRadius: "var(--sc-content-item-border-radius, 10px)",
|
|
332
|
+
border: "var(--sc-content-item-border, 1px solid rgba(0, 0, 0, 0.08))",
|
|
333
|
+
background: "var(--sc-content-item-background, rgba(255, 255, 255, 0.5))",
|
|
334
|
+
overflow: "hidden",
|
|
335
|
+
transition: "box-shadow 0.15s ease"
|
|
336
|
+
},
|
|
337
|
+
question: {
|
|
338
|
+
width: "100%",
|
|
339
|
+
padding: "var(--sc-content-item-padding, 12px 16px)",
|
|
340
|
+
display: "flex",
|
|
341
|
+
alignItems: "center",
|
|
342
|
+
justifyContent: "space-between",
|
|
343
|
+
border: "none",
|
|
344
|
+
cursor: "pointer",
|
|
345
|
+
fontSize: "var(--sc-content-item-font-size, 15px)",
|
|
346
|
+
fontWeight: 500,
|
|
347
|
+
textAlign: "left",
|
|
348
|
+
transition: "background-color 0.15s ease"
|
|
349
|
+
},
|
|
350
|
+
chevron: {
|
|
351
|
+
fontSize: "20px",
|
|
352
|
+
transition: "transform 0.2s ease",
|
|
353
|
+
color: "var(--sc-content-chevron-color, currentColor)"
|
|
354
|
+
},
|
|
355
|
+
answer: {
|
|
356
|
+
padding: "var(--sc-content-body-padding, 0 16px 12px 16px)",
|
|
357
|
+
fontSize: "var(--sc-content-body-font-size, 14px)",
|
|
358
|
+
lineHeight: 1.6,
|
|
359
|
+
overflow: "hidden",
|
|
360
|
+
transition: "max-height 0.2s ease, padding 0.2s ease"
|
|
361
|
+
},
|
|
362
|
+
category: {
|
|
363
|
+
display: "inline-block",
|
|
364
|
+
fontSize: "11px",
|
|
365
|
+
fontWeight: 600,
|
|
366
|
+
textTransform: "uppercase",
|
|
367
|
+
letterSpacing: "0.05em",
|
|
368
|
+
padding: "4px 8px",
|
|
369
|
+
borderRadius: "4px",
|
|
370
|
+
marginBottom: "8px"
|
|
371
|
+
},
|
|
372
|
+
categoryHeader: {
|
|
373
|
+
fontSize: "var(--sc-content-category-font-size, 12px)",
|
|
374
|
+
fontWeight: 700,
|
|
375
|
+
textTransform: "uppercase",
|
|
376
|
+
letterSpacing: "0.05em",
|
|
377
|
+
padding: "var(--sc-content-category-padding, 8px 4px 4px 4px)",
|
|
378
|
+
marginTop: "var(--sc-content-category-gap, 4px)"
|
|
379
|
+
},
|
|
380
|
+
feedback: {
|
|
381
|
+
display: "flex",
|
|
382
|
+
alignItems: "center",
|
|
383
|
+
gap: "8px",
|
|
384
|
+
marginTop: "12px",
|
|
385
|
+
paddingTop: "10px",
|
|
386
|
+
borderTop: "1px solid rgba(0, 0, 0, 0.08)",
|
|
387
|
+
fontSize: "13px"
|
|
388
|
+
},
|
|
389
|
+
feedbackButton: {
|
|
390
|
+
background: "none",
|
|
391
|
+
border: "1px solid transparent",
|
|
392
|
+
cursor: "pointer",
|
|
393
|
+
fontSize: "16px",
|
|
394
|
+
padding: "4px 8px",
|
|
395
|
+
borderRadius: "4px",
|
|
396
|
+
transition: "background-color 0.15s ease, border-color 0.15s ease"
|
|
397
|
+
},
|
|
398
|
+
feedbackButtonSelected: {
|
|
399
|
+
borderColor: "rgba(0, 0, 0, 0.2)",
|
|
400
|
+
backgroundColor: "rgba(0, 0, 0, 0.04)"
|
|
401
|
+
},
|
|
402
|
+
// ── Master→detail takeover ──────────────────────────────────────────────
|
|
403
|
+
// Item 5: the detail view is a tight stack — back, then the QUESTION with the
|
|
404
|
+
// ANSWER hugging right below it (the old 12px flex gap between Q and A wasted
|
|
405
|
+
// vertical space and pushed the answer down). Row spacing is now set by
|
|
406
|
+
// explicit per-element margins, so gap:0 here.
|
|
407
|
+
detail: {
|
|
408
|
+
display: "flex",
|
|
409
|
+
flexDirection: "column",
|
|
410
|
+
gap: "0",
|
|
411
|
+
minHeight: "0",
|
|
412
|
+
// Inset the master→detail takeover to match the list questions' padding so
|
|
413
|
+
// a single Q&A taking over the tile doesn't render flush to the edges.
|
|
414
|
+
// Vertical inset is tightened (12px→10px default) because the takeover lives
|
|
415
|
+
// in the fixed-height velvet deck band, where every px of chrome pushes the
|
|
416
|
+
// "Dive deeper" footer toward the card's overflow:hidden clip edge
|
|
417
|
+
// (BUG-1783496844). Horizontal stays 16px to keep alignment with the list.
|
|
418
|
+
padding: "var(--sc-content-item-padding, 10px 16px)"
|
|
419
|
+
},
|
|
420
|
+
detailBack: {
|
|
421
|
+
display: "inline-flex",
|
|
422
|
+
alignItems: "center",
|
|
423
|
+
gap: "4px",
|
|
424
|
+
alignSelf: "flex-start",
|
|
425
|
+
background: "none",
|
|
426
|
+
border: "none",
|
|
427
|
+
padding: "4px 0",
|
|
428
|
+
// Clear space below the back control (was the container gap). Tightened
|
|
429
|
+
// 10px→6px to reclaim vertical room in the bounded deck band so the
|
|
430
|
+
// "Dive deeper" footer stays clear of the card clip edge (BUG-1783496844).
|
|
431
|
+
marginBottom: "6px",
|
|
432
|
+
cursor: "pointer",
|
|
433
|
+
fontSize: "13px",
|
|
434
|
+
fontWeight: 600,
|
|
435
|
+
opacity: 0.85
|
|
436
|
+
},
|
|
437
|
+
detailQuestion: {
|
|
438
|
+
fontSize: "var(--sc-content-item-font-size, 16px)",
|
|
439
|
+
fontWeight: 600,
|
|
440
|
+
lineHeight: 1.4,
|
|
441
|
+
// Tight to the answer — kill the wasted Q↔A gap.
|
|
442
|
+
marginBottom: "4px"
|
|
443
|
+
},
|
|
444
|
+
detailAnswer: {
|
|
445
|
+
fontSize: "var(--sc-content-body-font-size, 14px)",
|
|
446
|
+
lineHeight: 1.6,
|
|
447
|
+
// No longer a long scroll region — the answer render is line-clamped
|
|
448
|
+
// (detailAnswerClamp) and "Dive deeper" carries the user to the full answer
|
|
449
|
+
// in chat. flex:1 keeps the footer (dive-deeper + feedback) pinned below.
|
|
450
|
+
display: "flex",
|
|
451
|
+
flexDirection: "column",
|
|
452
|
+
flex: "1 1 auto",
|
|
453
|
+
minHeight: "0"
|
|
454
|
+
},
|
|
455
|
+
// The answer TEXT render, capped to a few readable lines with a visible
|
|
456
|
+
// ellipsis cap (a declared -webkit-line-clamp box — the clip battery treats
|
|
457
|
+
// this as an intentional ellipsis, not a hidden-content bug). The cap is
|
|
458
|
+
// token-tunable so a roomy surface can raise it.
|
|
459
|
+
detailAnswerClamp: {
|
|
460
|
+
margin: "0",
|
|
461
|
+
display: "-webkit-box",
|
|
462
|
+
// Default cap lowered 4→3 lines: the takeover renders inside the
|
|
463
|
+
// fixed-height velvet deck card (overflow:hidden), so a 4-line preview
|
|
464
|
+
// plus chrome pushed the "Dive deeper" footer past the clip edge on
|
|
465
|
+
// smaller decks / two-line questions (BUG-1783496844). 3 lines is a
|
|
466
|
+
// compact preview; "Dive deeper" carries the full answer to chat. Roomy
|
|
467
|
+
// surfaces can raise it via the token.
|
|
468
|
+
// Capital-W vendor prefix: lowercase `webkitLineClamp` emits
|
|
469
|
+
// `webkit-line-clamp` (no leading dash), which the CSS parser drops —
|
|
470
|
+
// this clamp silently never applied until 2026-07-23.
|
|
471
|
+
WebkitLineClamp: "var(--sc-faq-answer-clamp, 3)",
|
|
472
|
+
WebkitBoxOrient: "vertical",
|
|
473
|
+
overflow: "hidden",
|
|
474
|
+
// Shrink-ready: as a flex child of detailAnswer, yield space first so that
|
|
475
|
+
// WHEN a host bounds the tile height (definite deck-card height) the answer
|
|
476
|
+
// gives up lines before the pinned footer is ever clipped.
|
|
477
|
+
flex: "1 1 auto",
|
|
478
|
+
minHeight: "0"
|
|
479
|
+
},
|
|
480
|
+
// Footer row under the answer: the "Dive deeper" chip sits bottom-right.
|
|
481
|
+
detailFooter: {
|
|
482
|
+
display: "flex",
|
|
483
|
+
alignItems: "center",
|
|
484
|
+
justifyContent: "flex-end",
|
|
485
|
+
gap: "8px",
|
|
486
|
+
// Tightened 8px→6px to reclaim deck-band vertical room (BUG-1783496844).
|
|
487
|
+
marginTop: "6px",
|
|
488
|
+
// Never shrink or clip the footer — the answer above yields space first.
|
|
489
|
+
flexShrink: 0
|
|
490
|
+
},
|
|
491
|
+
detailDiveDeeper: {
|
|
492
|
+
flex: "0 0 auto",
|
|
493
|
+
padding: "4px 10px",
|
|
494
|
+
borderRadius: "9999px",
|
|
495
|
+
border: "1px solid var(--sc-color-border, rgba(0,0,0,0.14))",
|
|
496
|
+
background: "transparent",
|
|
497
|
+
cursor: "pointer",
|
|
498
|
+
font: "600 12px/1.2 var(--sc-font-family, inherit)",
|
|
499
|
+
color: "var(--sc-color-primary, #3d8a5e)",
|
|
500
|
+
whiteSpace: "nowrap"
|
|
501
|
+
},
|
|
502
|
+
emptyState: {
|
|
503
|
+
textAlign: "center",
|
|
504
|
+
padding: "48px 24px",
|
|
505
|
+
fontSize: "14px"
|
|
506
|
+
},
|
|
507
|
+
noResults: {
|
|
508
|
+
textAlign: "center",
|
|
509
|
+
padding: "32px 16px",
|
|
510
|
+
fontSize: "14px"
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
var themeStyles = {
|
|
514
|
+
light: {
|
|
515
|
+
container: {
|
|
516
|
+
backgroundColor: "transparent",
|
|
517
|
+
color: "inherit"
|
|
518
|
+
},
|
|
519
|
+
searchInput: {
|
|
520
|
+
border: `1px solid ${slateGrey[11]}`
|
|
521
|
+
},
|
|
522
|
+
item: {
|
|
523
|
+
backgroundColor: "var(--sc-content-background)",
|
|
524
|
+
borderTop: "var(--sc-content-border)",
|
|
525
|
+
borderRight: "var(--sc-content-border)",
|
|
526
|
+
borderBottom: "var(--sc-content-border)",
|
|
527
|
+
borderLeft: "var(--sc-content-border)"
|
|
528
|
+
},
|
|
529
|
+
itemExpanded: {
|
|
530
|
+
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.08)"
|
|
531
|
+
},
|
|
532
|
+
question: {
|
|
533
|
+
backgroundColor: "transparent",
|
|
534
|
+
color: "var(--sc-content-text-color)"
|
|
535
|
+
},
|
|
536
|
+
questionHover: {
|
|
537
|
+
backgroundColor: "var(--sc-content-background-hover)"
|
|
538
|
+
},
|
|
539
|
+
answer: {
|
|
540
|
+
color: "var(--sc-content-text-secondary-color)"
|
|
541
|
+
},
|
|
542
|
+
category: {
|
|
543
|
+
backgroundColor: purple[8],
|
|
544
|
+
color: purple[2]
|
|
545
|
+
},
|
|
546
|
+
categoryHeader: {
|
|
547
|
+
color: slateGrey[7]
|
|
548
|
+
},
|
|
549
|
+
emptyState: {
|
|
550
|
+
color: slateGrey[8]
|
|
551
|
+
},
|
|
552
|
+
feedbackPrompt: {
|
|
553
|
+
color: slateGrey[7]
|
|
554
|
+
}
|
|
555
|
+
},
|
|
556
|
+
dark: {
|
|
557
|
+
container: {
|
|
558
|
+
backgroundColor: "transparent",
|
|
559
|
+
color: "inherit"
|
|
560
|
+
},
|
|
561
|
+
searchInput: {
|
|
562
|
+
border: `1px solid ${slateGrey[5]}`
|
|
563
|
+
},
|
|
564
|
+
item: {
|
|
565
|
+
backgroundColor: "var(--sc-content-background)",
|
|
566
|
+
borderTop: "var(--sc-content-border)",
|
|
567
|
+
borderRight: "var(--sc-content-border)",
|
|
568
|
+
borderBottom: "var(--sc-content-border)",
|
|
569
|
+
borderLeft: "var(--sc-content-border)"
|
|
570
|
+
},
|
|
571
|
+
itemExpanded: {
|
|
572
|
+
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)"
|
|
573
|
+
},
|
|
574
|
+
question: {
|
|
575
|
+
backgroundColor: "transparent",
|
|
576
|
+
color: "var(--sc-content-text-color)"
|
|
577
|
+
},
|
|
578
|
+
questionHover: {
|
|
579
|
+
backgroundColor: "var(--sc-content-background-hover)"
|
|
580
|
+
},
|
|
581
|
+
answer: {
|
|
582
|
+
color: "var(--sc-content-text-secondary-color)"
|
|
583
|
+
},
|
|
584
|
+
category: {
|
|
585
|
+
backgroundColor: purple[0],
|
|
586
|
+
color: purple[6]
|
|
587
|
+
},
|
|
588
|
+
categoryHeader: {
|
|
589
|
+
color: slateGrey[8]
|
|
590
|
+
},
|
|
591
|
+
emptyState: {
|
|
592
|
+
color: slateGrey[7]
|
|
593
|
+
},
|
|
594
|
+
feedbackPrompt: {
|
|
595
|
+
color: slateGrey[8]
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
// src/renderHealth.ts
|
|
601
|
+
var BLANK_MIN_AREA = 60 * 60;
|
|
602
|
+
function elementPath(el, stopAt) {
|
|
603
|
+
const seg = (e) => {
|
|
604
|
+
const tag = e.tagName.toLowerCase();
|
|
605
|
+
const cls = typeof e.className === "string" && e.className.trim() ? `.${e.className.trim().split(/\s+/)[0]}` : "";
|
|
606
|
+
return `${tag}${cls}`;
|
|
607
|
+
};
|
|
608
|
+
const parts = [seg(el)];
|
|
609
|
+
let cur = el.parentElement;
|
|
610
|
+
let hops = 0;
|
|
611
|
+
while (cur && cur !== stopAt && hops < 3) {
|
|
612
|
+
parts.unshift(seg(cur));
|
|
613
|
+
cur = cur.parentElement;
|
|
614
|
+
hops++;
|
|
615
|
+
}
|
|
616
|
+
return parts.join(">");
|
|
617
|
+
}
|
|
618
|
+
function hasClipOkAncestor(el, root) {
|
|
619
|
+
let cur = el;
|
|
620
|
+
while (cur && cur !== root.parentElement) {
|
|
621
|
+
if (cur.hasAttribute?.("data-clip-ok")) return true;
|
|
622
|
+
cur = cur.parentElement;
|
|
623
|
+
}
|
|
624
|
+
return false;
|
|
625
|
+
}
|
|
626
|
+
function measureRenderHealth(root) {
|
|
627
|
+
const clips = [];
|
|
628
|
+
const seen = /* @__PURE__ */ new Set();
|
|
629
|
+
const push = (f) => {
|
|
630
|
+
const k = `${f.path}|${f.over_px}`;
|
|
631
|
+
if (seen.has(k)) return;
|
|
632
|
+
seen.add(k);
|
|
633
|
+
clips.push(f);
|
|
634
|
+
};
|
|
635
|
+
let hasText = false;
|
|
636
|
+
let hasCompleteImage = false;
|
|
637
|
+
for (const el of root.querySelectorAll("*")) {
|
|
638
|
+
const cs = getComputedStyle(el);
|
|
639
|
+
if (cs.display === "none" || cs.visibility === "hidden") continue;
|
|
640
|
+
const r = el.getBoundingClientRect();
|
|
641
|
+
if (r.width === 0 || r.height === 0) continue;
|
|
642
|
+
const ownText = Array.from(el.childNodes).some(
|
|
643
|
+
(n) => n.nodeType === 3 && (n.textContent ?? "").trim().length > 0
|
|
644
|
+
);
|
|
645
|
+
if (ownText) hasText = true;
|
|
646
|
+
if (el.tagName === "IMG") {
|
|
647
|
+
const img = el;
|
|
648
|
+
if (img.complete && img.naturalWidth > 0 && img.dataset.imageError !== "true") {
|
|
649
|
+
hasCompleteImage = true;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
if (ownText && el.scrollWidth > el.clientWidth + 1 && cs.textOverflow !== "ellipsis" && !hasClipOkAncestor(el, root)) {
|
|
653
|
+
push({ path: elementPath(el, root), over_px: el.scrollWidth - el.clientWidth });
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
const rect = root.getBoundingClientRect();
|
|
657
|
+
const area = rect.width * rect.height;
|
|
658
|
+
const blank = area >= BLANK_MIN_AREA && !hasText && !hasCompleteImage;
|
|
659
|
+
return { clips, blank };
|
|
660
|
+
}
|
|
661
|
+
function synEmit(name, props) {
|
|
662
|
+
if (typeof window === "undefined") return;
|
|
663
|
+
try {
|
|
664
|
+
const emit = window.SynOS?.runtime?.events?.emit;
|
|
665
|
+
if (typeof emit === "function") emit(name, props);
|
|
666
|
+
} catch {
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
var CLEAN = { clips: [], blank: false };
|
|
670
|
+
function reportRenderHealth(root, opts) {
|
|
671
|
+
try {
|
|
672
|
+
const health = measureRenderHealth(root);
|
|
673
|
+
if (health.clips.length === 0 && !health.blank) return health;
|
|
674
|
+
const base = { ...opts.context ?? {}, widget_kind: opts.widgetKind };
|
|
675
|
+
if (health.blank) {
|
|
676
|
+
synEmit(`app:${opts.category}:blank`, base);
|
|
677
|
+
return health;
|
|
678
|
+
}
|
|
679
|
+
const paths = health.clips.map((c) => c.path).sort();
|
|
680
|
+
const over_px = health.clips.reduce((m, c) => Math.max(m, c.over_px), 0);
|
|
681
|
+
synEmit(`app:${opts.category}:clipped`, {
|
|
682
|
+
paths: paths.join(","),
|
|
683
|
+
over_px,
|
|
684
|
+
clip_count: health.clips.length,
|
|
685
|
+
...base
|
|
686
|
+
});
|
|
687
|
+
return health;
|
|
688
|
+
} catch {
|
|
689
|
+
return CLEAN;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
var _probe, _reported, _raf, _RenderHealthController_instances, schedule_fn, run_fn, cancel_fn;
|
|
693
|
+
var RenderHealthController = class {
|
|
694
|
+
constructor(host, probe) {
|
|
695
|
+
__privateAdd(this, _RenderHealthController_instances);
|
|
696
|
+
__privateAdd(this, _probe);
|
|
697
|
+
__privateAdd(this, _reported, /* @__PURE__ */ new Set());
|
|
698
|
+
__privateAdd(this, _raf);
|
|
699
|
+
__privateSet(this, _probe, probe);
|
|
700
|
+
host.addController(this);
|
|
701
|
+
}
|
|
702
|
+
hostUpdated() {
|
|
703
|
+
__privateMethod(this, _RenderHealthController_instances, schedule_fn).call(this);
|
|
704
|
+
}
|
|
705
|
+
hostDisconnected() {
|
|
706
|
+
__privateMethod(this, _RenderHealthController_instances, cancel_fn).call(this);
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
_probe = new WeakMap();
|
|
710
|
+
_reported = new WeakMap();
|
|
711
|
+
_raf = new WeakMap();
|
|
712
|
+
_RenderHealthController_instances = new WeakSet();
|
|
713
|
+
schedule_fn = function() {
|
|
714
|
+
if (typeof window === "undefined") return;
|
|
715
|
+
if (__privateGet(this, _raf) !== void 0) return;
|
|
716
|
+
const raf = typeof window.requestAnimationFrame === "function" ? window.requestAnimationFrame.bind(window) : (cb) => window.setTimeout(() => cb(0), 0);
|
|
717
|
+
__privateSet(this, _raf, raf(() => {
|
|
718
|
+
window.setTimeout(() => {
|
|
719
|
+
__privateSet(this, _raf, void 0);
|
|
720
|
+
__privateMethod(this, _RenderHealthController_instances, run_fn).call(this);
|
|
721
|
+
}, 0);
|
|
722
|
+
}));
|
|
723
|
+
};
|
|
724
|
+
run_fn = function() {
|
|
725
|
+
try {
|
|
726
|
+
const probe = __privateGet(this, _probe).call(this);
|
|
727
|
+
if (!probe || !probe.root) return;
|
|
728
|
+
if (__privateGet(this, _reported).has(probe.stateKey)) return;
|
|
729
|
+
__privateGet(this, _reported).add(probe.stateKey);
|
|
730
|
+
reportRenderHealth(probe.root, probe);
|
|
731
|
+
} catch {
|
|
732
|
+
}
|
|
733
|
+
};
|
|
734
|
+
cancel_fn = function() {
|
|
735
|
+
if (__privateGet(this, _raf) !== void 0 && typeof window !== "undefined" && typeof window.cancelAnimationFrame === "function") {
|
|
736
|
+
window.cancelAnimationFrame(__privateGet(this, _raf));
|
|
737
|
+
}
|
|
738
|
+
__privateSet(this, _raf, void 0);
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
// src/FAQWidgetLit.ts
|
|
742
|
+
function sm(styles) {
|
|
743
|
+
return styles;
|
|
744
|
+
}
|
|
745
|
+
function resolveFeedbackConfig(feedback) {
|
|
746
|
+
if (!feedback) return null;
|
|
747
|
+
if (feedback === true) return { style: "thumbs" };
|
|
748
|
+
return feedback;
|
|
749
|
+
}
|
|
750
|
+
function getFeedbackPrompt(feedbackConfig) {
|
|
751
|
+
return feedbackConfig.prompt ?? "Was this helpful?";
|
|
752
|
+
}
|
|
753
|
+
function hashFaqId(text) {
|
|
754
|
+
let h = 5381;
|
|
755
|
+
for (let i = 0; i < text.length; i++) {
|
|
756
|
+
h = (Math.imul(h, 33) ^ text.charCodeAt(i)) >>> 0;
|
|
757
|
+
}
|
|
758
|
+
return h.toString(36);
|
|
759
|
+
}
|
|
760
|
+
function cssAttrEscape(value) {
|
|
761
|
+
return value.replace(/["\\]/g, "\\$&");
|
|
762
|
+
}
|
|
763
|
+
function resolveTheme(theme) {
|
|
764
|
+
if (theme && theme !== "auto") return theme;
|
|
765
|
+
if (typeof window !== "undefined") {
|
|
766
|
+
return window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
767
|
+
}
|
|
768
|
+
return "light";
|
|
769
|
+
}
|
|
770
|
+
var DEFAULT_INSTANCE_ID = "faq-widget";
|
|
771
|
+
var _faqUidCounter = 0;
|
|
772
|
+
var _FAQAccordionElement_instances, healthProbe_fn;
|
|
773
|
+
var FAQAccordionElement = class extends LitElement {
|
|
774
|
+
constructor() {
|
|
775
|
+
super();
|
|
776
|
+
__privateAdd(this, _FAQAccordionElement_instances);
|
|
777
|
+
// -----------------------------------------------------------------------
|
|
778
|
+
// Property declarations
|
|
779
|
+
// -----------------------------------------------------------------------
|
|
780
|
+
this.faqConfig = {
|
|
781
|
+
expandBehavior: "single",
|
|
782
|
+
searchable: false,
|
|
783
|
+
theme: "auto",
|
|
784
|
+
actions: []
|
|
785
|
+
};
|
|
786
|
+
this.runtime = null;
|
|
787
|
+
this.instanceId = DEFAULT_INSTANCE_ID;
|
|
788
|
+
// Per-element generated scope uid, used ONLY when `instanceId` is left at the
|
|
789
|
+
// shared default so two default-id tiles don't cross-scope their injected
|
|
790
|
+
// <style> (both would otherwise target `[data-adaptive-id="faq-widget"]`).
|
|
791
|
+
// Generated lazily and once per element (see {@link _scopeId}).
|
|
792
|
+
this._generatedUid = null;
|
|
793
|
+
// Internal state.
|
|
794
|
+
// The master→detail takeover swaps the whole widget to a single Q&A (so a long
|
|
795
|
+
// answer never overflows the bounded velvet tile) via a FLIP: front = the
|
|
796
|
+
// question list, back = the open question's detail. The flip's state machine —
|
|
797
|
+
// open id, active face, animating flag, direction, and the token-coupled
|
|
798
|
+
// settle timer with its GPU-teardown/reduced-motion contract — lives in the
|
|
799
|
+
// shared canvas FlipController; the FAQ keeps only its own concerns (focus,
|
|
800
|
+
// ids, content) here. `sc-faq-flip` keyframes + the `faq` attribute namespace
|
|
801
|
+
// match what {@link _renderFlipStyles} emits.
|
|
802
|
+
this._flip = new FlipController(this, { keyframePrefix: "sc-faq-flip" });
|
|
803
|
+
this._highlightId = null;
|
|
804
|
+
this._searchQuery = "";
|
|
805
|
+
this._feedbackState = /* @__PURE__ */ new Map();
|
|
806
|
+
this._hoveredId = null;
|
|
807
|
+
// Focus management (a11y): after a flip renders, move focus to the face that
|
|
808
|
+
// just became active — the ‹ Back button on flip-to-back, and the originating
|
|
809
|
+
// question row on flip-to-front — so keyboard/AT users follow the takeover.
|
|
810
|
+
// Consumed (and cleared) in `updated()`. `_focusReturnId` remembers WHICH row
|
|
811
|
+
// opened the detail so focus returns to that exact row, not just the first.
|
|
812
|
+
this._pendingFocus = null;
|
|
813
|
+
this._focusReturnId = null;
|
|
814
|
+
// Subscription cleanup handles
|
|
815
|
+
this._unsubContext = null;
|
|
816
|
+
this._unsubAccumulator = null;
|
|
817
|
+
this._unsubStateChanged = null;
|
|
818
|
+
this._unsubCta = null;
|
|
819
|
+
this._unsubDeepLink = null;
|
|
820
|
+
this._unsubSessionMetrics = null;
|
|
821
|
+
this._highlightTimer = null;
|
|
822
|
+
// Compositional-child wiring — faq:question items the LLM mounts into this
|
|
823
|
+
// accordion's tile arrive as `element.compositional_append` events (see
|
|
824
|
+
// adaptive-chatbot's ItemHandler). Without this subscription the questions
|
|
825
|
+
// are published into the void and the accordion renders empty.
|
|
826
|
+
this._unsubCompositional = null;
|
|
827
|
+
this._tileId = null;
|
|
828
|
+
/** Instance ids already appended via the compositional bus — dedups
|
|
829
|
+
* the subscribe-then-replay path (and re-subscribes on runtime change). */
|
|
830
|
+
this._llmAppendedIds = /* @__PURE__ */ new Set();
|
|
831
|
+
new RenderHealthController(this, () => __privateMethod(this, _FAQAccordionElement_instances, healthProbe_fn).call(this));
|
|
832
|
+
}
|
|
833
|
+
// Read-through accessors so the render + the flip test-suite see the flip
|
|
834
|
+
// state without duplicating it. `_openId` null = LIST view; a value = the open
|
|
835
|
+
// question. `_activeFace` drives the CSS rotateY.
|
|
836
|
+
get _openId() {
|
|
837
|
+
return this._flip.openId;
|
|
838
|
+
}
|
|
839
|
+
get _activeFace() {
|
|
840
|
+
return this._flip.activeFace;
|
|
841
|
+
}
|
|
842
|
+
get _flipAnimating() {
|
|
843
|
+
return this._flip.animating;
|
|
844
|
+
}
|
|
845
|
+
// -----------------------------------------------------------------------
|
|
846
|
+
// Light DOM — no Shadow DOM so CSS variables from the host page apply
|
|
847
|
+
// -----------------------------------------------------------------------
|
|
848
|
+
createRenderRoot() {
|
|
849
|
+
return this;
|
|
850
|
+
}
|
|
851
|
+
// -----------------------------------------------------------------------
|
|
852
|
+
// Lifecycle
|
|
853
|
+
// -----------------------------------------------------------------------
|
|
854
|
+
connectedCallback() {
|
|
855
|
+
super.connectedCallback();
|
|
856
|
+
this._subscribeAll();
|
|
857
|
+
}
|
|
858
|
+
disconnectedCallback() {
|
|
859
|
+
super.disconnectedCallback();
|
|
860
|
+
this._unsubscribeAll();
|
|
861
|
+
if (this._highlightTimer !== null) {
|
|
862
|
+
clearTimeout(this._highlightTimer);
|
|
863
|
+
this._highlightTimer = null;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
// Re-subscribe when runtime changes (property may be set after connectedCallback)
|
|
867
|
+
updated(changedProps) {
|
|
868
|
+
if (changedProps.has("runtime")) {
|
|
869
|
+
this._unsubscribeAll();
|
|
870
|
+
this._subscribeAll();
|
|
871
|
+
}
|
|
872
|
+
this._applyPendingFocus();
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Move focus to the face that just became active after a flip (a11y). On
|
|
876
|
+
* flip-to-back, focus the ‹ Back button; on flip-to-front, focus the question
|
|
877
|
+
* row that originally opened the detail (so keyboard/AT users land back where
|
|
878
|
+
* they were). No-op when nothing is pending or the target isn't in the DOM
|
|
879
|
+
* yet (the flip-to-front row may not exist if the list changed).
|
|
880
|
+
*/
|
|
881
|
+
_applyPendingFocus() {
|
|
882
|
+
if (this._pendingFocus === null) return;
|
|
883
|
+
const target = this._pendingFocus;
|
|
884
|
+
this._pendingFocus = null;
|
|
885
|
+
if (target === "back") {
|
|
886
|
+
const back = this.querySelector("[data-faq-back]");
|
|
887
|
+
back?.focus();
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
const id = this._focusReturnId;
|
|
891
|
+
this._focusReturnId = null;
|
|
892
|
+
if (!id) return;
|
|
893
|
+
const row = this.querySelector(`[data-faq-item-id="${cssAttrEscape(id)}"] button`);
|
|
894
|
+
row?.focus();
|
|
895
|
+
}
|
|
896
|
+
// -----------------------------------------------------------------------
|
|
897
|
+
// Subscription management
|
|
898
|
+
// -----------------------------------------------------------------------
|
|
899
|
+
_subscribeAll() {
|
|
900
|
+
if (!this.runtime) return;
|
|
901
|
+
this._unsubContext = this.runtime.context.subscribe(() => {
|
|
902
|
+
this._reevaluateOpenAndUpdate();
|
|
903
|
+
});
|
|
904
|
+
if (this.runtime.accumulator?.subscribe) {
|
|
905
|
+
this._unsubAccumulator = this.runtime.accumulator.subscribe(() => {
|
|
906
|
+
this._reevaluateOpenAndUpdate();
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
if (this.runtime.sessionMetrics?.subscribe) {
|
|
910
|
+
this._unsubSessionMetrics = this.runtime.sessionMetrics.subscribe(() => {
|
|
911
|
+
this._reevaluateOpenAndUpdate();
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
if (this.runtime.events.subscribe) {
|
|
915
|
+
this._unsubStateChanged = this.runtime.events.subscribe({ names: ["state.changed"] }, () => {
|
|
916
|
+
this._reevaluateOpenAndUpdate();
|
|
917
|
+
});
|
|
918
|
+
if (this.runtime.events.getRecent) {
|
|
919
|
+
const recentEvents = this.runtime.events.getRecent(
|
|
920
|
+
{ patterns: ["^action\\.tooltip_cta_clicked$", "^action\\.modal_cta_clicked$"] },
|
|
921
|
+
10
|
|
922
|
+
);
|
|
923
|
+
const pendingEvent = recentEvents.filter((e) => {
|
|
924
|
+
const actionId = e.props?.actionId;
|
|
925
|
+
return typeof actionId === "string" && actionId.startsWith("faq:open:");
|
|
926
|
+
}).pop();
|
|
927
|
+
if (pendingEvent && Date.now() - pendingEvent.ts < 1e4) {
|
|
928
|
+
const questionId = pendingEvent.props.actionId.replace("faq:open:", "");
|
|
929
|
+
this._openToDetail(questionId);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
this._unsubCta = this.runtime.events.subscribe(
|
|
933
|
+
{ patterns: ["^action\\.tooltip_cta_clicked$", "^action\\.modal_cta_clicked$"] },
|
|
934
|
+
(event) => {
|
|
935
|
+
const ts = event.ts;
|
|
936
|
+
if (typeof ts === "number" && Date.now() - ts >= 1e4) return;
|
|
937
|
+
const actionId = event.props?.actionId;
|
|
938
|
+
if (typeof actionId !== "string" || !actionId.startsWith("faq:open:")) return;
|
|
939
|
+
const questionId = actionId.replace("faq:open:", "");
|
|
940
|
+
this._openToDetail(questionId);
|
|
941
|
+
this.runtime?.events.publish("canvas.requestOpen");
|
|
942
|
+
}
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
if (this.runtime.events.subscribe) {
|
|
946
|
+
const handleDeepLink = (event) => {
|
|
947
|
+
const tileId = event.props?.tileId;
|
|
948
|
+
const itemId = event.props?.itemId;
|
|
949
|
+
if (tileId !== this.instanceId) return;
|
|
950
|
+
if (!itemId) return;
|
|
951
|
+
this._openToDetail(itemId);
|
|
952
|
+
this._highlightId = itemId;
|
|
953
|
+
if (this._highlightTimer !== null) clearTimeout(this._highlightTimer);
|
|
954
|
+
this._highlightTimer = setTimeout(() => {
|
|
955
|
+
this._highlightId = null;
|
|
956
|
+
this._highlightTimer = null;
|
|
957
|
+
}, 1500);
|
|
958
|
+
};
|
|
959
|
+
if (this.runtime.events.getRecent) {
|
|
960
|
+
const recent = this.runtime.events.getRecent({ names: ["notification.deep_link"] }, 5);
|
|
961
|
+
const pending = recent.filter((e) => e.props?.tileId === this.instanceId && e.props?.itemId).pop();
|
|
962
|
+
if (pending && Date.now() - pending.ts < 1e4) {
|
|
963
|
+
handleDeepLink(pending);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
this._unsubDeepLink = this.runtime.events.subscribe(
|
|
967
|
+
{ names: ["notification.deep_link"] },
|
|
968
|
+
(event) => {
|
|
969
|
+
const ts = event.ts;
|
|
970
|
+
if (typeof ts === "number" && Date.now() - ts >= 1e4) return;
|
|
971
|
+
handleDeepLink(event);
|
|
972
|
+
}
|
|
973
|
+
);
|
|
974
|
+
}
|
|
975
|
+
this._subscribeCompositional();
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* Subscribe to `element.compositional_*` events targeting this accordion's
|
|
979
|
+
* tile, and ask the element store to replay any items it already holds for
|
|
980
|
+
* us (covers the case where the accordion mounts AFTER the item did — the
|
|
981
|
+
* inline-slot hydration race that otherwise silently drops the question).
|
|
982
|
+
*/
|
|
983
|
+
_subscribeCompositional() {
|
|
984
|
+
const bus = this.runtime?.events;
|
|
985
|
+
if (!bus?.subscribe) return;
|
|
986
|
+
this._tileId = this._resolveTileId();
|
|
987
|
+
if (!this._tileId) return;
|
|
988
|
+
const tileId = this._tileId;
|
|
989
|
+
this._unsubCompositional = bus.subscribe((event) => {
|
|
990
|
+
const props = event.props ?? {};
|
|
991
|
+
if (props.tile_id !== tileId) return;
|
|
992
|
+
const instanceId = String(props.instance_id ?? "");
|
|
993
|
+
if (event.name === "element.compositional_append") {
|
|
994
|
+
this._insertItem(
|
|
995
|
+
instanceId,
|
|
996
|
+
props.item,
|
|
997
|
+
props.position ?? "append"
|
|
998
|
+
);
|
|
999
|
+
} else if (event.name === "element.compositional_patch") {
|
|
1000
|
+
this._patchItem(instanceId, props.item);
|
|
1001
|
+
} else if (event.name === "element.compositional_remove") {
|
|
1002
|
+
this._removeItem(instanceId);
|
|
1003
|
+
}
|
|
1004
|
+
});
|
|
1005
|
+
bus.publish("element.compositional_replay_request", { tile_id: tileId });
|
|
1006
|
+
}
|
|
1007
|
+
/** Tile id for compositional filtering: the enclosing tile card's
|
|
1008
|
+
* `data-tile-id` (set by SyntroTileCard), falling back to `instanceId`
|
|
1009
|
+
* when the accordion is mounted outside a tile card. */
|
|
1010
|
+
_resolveTileId() {
|
|
1011
|
+
const anchor = this.closest("[data-tile-id]");
|
|
1012
|
+
const fromDom = anchor?.getAttribute("data-tile-id");
|
|
1013
|
+
if (fromDom) return fromDom;
|
|
1014
|
+
return this.instanceId && this.instanceId !== DEFAULT_INSTANCE_ID ? this.instanceId : null;
|
|
1015
|
+
}
|
|
1016
|
+
/** Append (or prepend) a faq:question the LLM mounted. The wire shape is a
|
|
1017
|
+
* fully-formed `{kind, config}` envelope, so we store it verbatim. */
|
|
1018
|
+
_insertItem(instanceId, item, position) {
|
|
1019
|
+
if (!instanceId || !item?.config?.id) return;
|
|
1020
|
+
if (this._llmAppendedIds.has(instanceId)) return;
|
|
1021
|
+
this._llmAppendedIds.add(instanceId);
|
|
1022
|
+
const actions = this.faqConfig.actions ?? [];
|
|
1023
|
+
const next = position === "prepend" ? [item, ...actions] : [...actions, item];
|
|
1024
|
+
this.faqConfig = { ...this.faqConfig, actions: next };
|
|
1025
|
+
}
|
|
1026
|
+
/** Replace an existing question's content (full replacement). */
|
|
1027
|
+
_patchItem(instanceId, item) {
|
|
1028
|
+
if (!instanceId || !item?.config) return;
|
|
1029
|
+
const actions = this.faqConfig.actions ?? [];
|
|
1030
|
+
const idx = actions.findIndex((a) => a.config.id === instanceId);
|
|
1031
|
+
if (idx < 0) return;
|
|
1032
|
+
const next = [...actions];
|
|
1033
|
+
next[idx] = item;
|
|
1034
|
+
if (this._flip.openId === instanceId && item.config.id !== instanceId) {
|
|
1035
|
+
this._flip.openId = item.config.id;
|
|
1036
|
+
}
|
|
1037
|
+
this.faqConfig = { ...this.faqConfig, actions: next };
|
|
1038
|
+
this._resetFaceIfOpenStale();
|
|
1039
|
+
}
|
|
1040
|
+
/** Remove a question by instance id. */
|
|
1041
|
+
_removeItem(instanceId) {
|
|
1042
|
+
if (!instanceId) return;
|
|
1043
|
+
const actions = this.faqConfig.actions ?? [];
|
|
1044
|
+
const next = actions.filter((a) => a.config.id !== instanceId);
|
|
1045
|
+
if (next.length === actions.length) return;
|
|
1046
|
+
this._llmAppendedIds.delete(instanceId);
|
|
1047
|
+
if (this._flip.openId === instanceId) {
|
|
1048
|
+
this._flip.reset();
|
|
1049
|
+
this._pendingFocus = null;
|
|
1050
|
+
this._focusReturnId = null;
|
|
1051
|
+
}
|
|
1052
|
+
this.faqConfig = { ...this.faqConfig, actions: next };
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* Re-evaluate whether the open question is still visible, then re-render.
|
|
1056
|
+
* Called from the subscription-driven re-render paths (context / accumulator /
|
|
1057
|
+
* sessionMetric changes). Those subscriptions can flip an open question's
|
|
1058
|
+
* `triggerWhen` to false WITHOUT a compositional patch/remove — so without
|
|
1059
|
+
* this hook the stale-open reset (`_resetFaceIfOpenStale`, otherwise only
|
|
1060
|
+
* invoked on patch/remove) would never fire on the triggerWhen path, and an
|
|
1061
|
+
* open question gated out this way would snap straight to the back face on
|
|
1062
|
+
* re-appearance. `_resetFaceIfOpenStale` mutates reactive state (which itself
|
|
1063
|
+
* schedules an update), and we still `requestUpdate()` for the no-op case.
|
|
1064
|
+
*/
|
|
1065
|
+
_reevaluateOpenAndUpdate() {
|
|
1066
|
+
this._resetFaceIfOpenStale();
|
|
1067
|
+
this.requestUpdate();
|
|
1068
|
+
}
|
|
1069
|
+
/**
|
|
1070
|
+
* If `_openId` no longer resolves against the visible/ordered list (gated out
|
|
1071
|
+
* by triggerWhen, removed, or patched away), reset BOTH `_openId` and
|
|
1072
|
+
* `_activeFace` to the front (and clear the pending a11y focus intent).
|
|
1073
|
+
* Resolves against the SAME `ordered` list render() uses (visible + ordered,
|
|
1074
|
+
* NOT search-filtered). Also cancels any pending back-clear timer. Invoked on
|
|
1075
|
+
* compositional patch/remove AND on the subscription-driven re-render paths
|
|
1076
|
+
* (via {@link _reevaluateOpenAndUpdate}) so the triggerWhen-gated-out case is
|
|
1077
|
+
* genuinely covered. Keeping `_activeFace` on 'back' after the open id went
|
|
1078
|
+
* stale is the bug this guards: render() would force the front face locally
|
|
1079
|
+
* but leave state on 'back', so a later re-appearance snaps to the back face
|
|
1080
|
+
* with no animation.
|
|
1081
|
+
*/
|
|
1082
|
+
_resetFaceIfOpenStale() {
|
|
1083
|
+
if (this._flip.openId === null) return;
|
|
1084
|
+
const ordered = this._orderedQuestions(this._visibleQuestions());
|
|
1085
|
+
const stillPresent = ordered.some((q) => q.config.id === this._flip.openId);
|
|
1086
|
+
if (stillPresent) return;
|
|
1087
|
+
this._flip.reset();
|
|
1088
|
+
this._pendingFocus = null;
|
|
1089
|
+
this._focusReturnId = null;
|
|
1090
|
+
}
|
|
1091
|
+
_unsubscribeAll() {
|
|
1092
|
+
this._unsubContext?.();
|
|
1093
|
+
this._unsubAccumulator?.();
|
|
1094
|
+
this._unsubSessionMetrics?.();
|
|
1095
|
+
this._unsubStateChanged?.();
|
|
1096
|
+
this._unsubCta?.();
|
|
1097
|
+
this._unsubDeepLink?.();
|
|
1098
|
+
this._unsubCompositional?.();
|
|
1099
|
+
this._unsubContext = null;
|
|
1100
|
+
this._unsubAccumulator = null;
|
|
1101
|
+
this._unsubSessionMetrics = null;
|
|
1102
|
+
this._unsubStateChanged = null;
|
|
1103
|
+
this._unsubCta = null;
|
|
1104
|
+
this._unsubDeepLink = null;
|
|
1105
|
+
this._unsubCompositional = null;
|
|
1106
|
+
}
|
|
1107
|
+
// -----------------------------------------------------------------------
|
|
1108
|
+
// Handlers
|
|
1109
|
+
// -----------------------------------------------------------------------
|
|
1110
|
+
/**
|
|
1111
|
+
* Master→detail takeover: tapping a list row opens its single-Q&A detail
|
|
1112
|
+
* view (the whole widget swaps), so a long answer fills — and never
|
|
1113
|
+
* overflows — the bounded velvet tile. There is no simultaneous multi-expand;
|
|
1114
|
+
* one question owns the tile at a time.
|
|
1115
|
+
*/
|
|
1116
|
+
_handleOpen(id) {
|
|
1117
|
+
this._openToDetail(id);
|
|
1118
|
+
this.runtime?.events.publish("faq:toggled", {
|
|
1119
|
+
instanceId: this.instanceId,
|
|
1120
|
+
questionId: id,
|
|
1121
|
+
expanded: true,
|
|
1122
|
+
timestamp: Date.now()
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
/**
|
|
1126
|
+
* Put the widget into the DETAIL (back) face for `id` and flip to it. Shared
|
|
1127
|
+
* by the tap handler and the event-driven entry points (faq:open CTA,
|
|
1128
|
+
* notification.deep_link) so every "open this question" path flips the same
|
|
1129
|
+
* way. Cancels any pending back-clear timer (a re-open before the flip-out
|
|
1130
|
+
* finishes must keep the detail mounted). Remembers the originating question
|
|
1131
|
+
* id so focus can return to that exact row on flip-to-front (a11y), and moves
|
|
1132
|
+
* focus to the ‹ Back button once the back face renders.
|
|
1133
|
+
*/
|
|
1134
|
+
_openToDetail(id) {
|
|
1135
|
+
this._pendingFocus = "back";
|
|
1136
|
+
this._focusReturnId = id;
|
|
1137
|
+
this._flip.openTo(id);
|
|
1138
|
+
}
|
|
1139
|
+
/** Back affordance: FLIP from the detail (back) face to the list (front) face.
|
|
1140
|
+
* The FlipController keeps `openId` populated through the rotation (never
|
|
1141
|
+
* blank mid-flip) and clears it on settle — with reduced motion it clears
|
|
1142
|
+
* synchronously. Focus returns to the originating question row. */
|
|
1143
|
+
_handleBack() {
|
|
1144
|
+
const id = this._flip.openId;
|
|
1145
|
+
this._pendingFocus = "front";
|
|
1146
|
+
this._flip.back();
|
|
1147
|
+
if (id !== null) {
|
|
1148
|
+
this.runtime?.events.publish("faq:toggled", {
|
|
1149
|
+
instanceId: this.instanceId,
|
|
1150
|
+
questionId: id,
|
|
1151
|
+
expanded: false,
|
|
1152
|
+
timestamp: Date.now()
|
|
1153
|
+
});
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
_handleFeedback(itemId, question, value) {
|
|
1157
|
+
const next = new Map(this._feedbackState);
|
|
1158
|
+
next.set(itemId, value);
|
|
1159
|
+
this._feedbackState = next;
|
|
1160
|
+
this.runtime?.events.publish("faq:feedback", { itemId, question, value });
|
|
1161
|
+
}
|
|
1162
|
+
// -----------------------------------------------------------------------
|
|
1163
|
+
// Computed helpers
|
|
1164
|
+
// -----------------------------------------------------------------------
|
|
1165
|
+
/**
|
|
1166
|
+
* Unified render list. Merges compositionally-appended rows
|
|
1167
|
+
* (`faqConfig.actions`, the container-then-stream path) with atomically
|
|
1168
|
+
* authored rows (`faqConfig.questions`, the struct_list path) normalized
|
|
1169
|
+
* into the same `FAQQuestionAction` shape. The atomic path is how the LLM
|
|
1170
|
+
* mounts a complete FAQ in one call — so it renders whole, never empty.
|
|
1171
|
+
*/
|
|
1172
|
+
_allQuestions() {
|
|
1173
|
+
const compositional = this.faqConfig.actions ?? [];
|
|
1174
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1175
|
+
const atomic = (this.faqConfig.questions ?? []).map((q) => {
|
|
1176
|
+
let id = `atomic-${hashFaqId(q.question)}`;
|
|
1177
|
+
const dup = seen.get(id) ?? 0;
|
|
1178
|
+
seen.set(id, dup + 1);
|
|
1179
|
+
if (dup > 0) id = `${id}-${dup}`;
|
|
1180
|
+
return {
|
|
1181
|
+
kind: "faq:question",
|
|
1182
|
+
config: {
|
|
1183
|
+
id,
|
|
1184
|
+
question: q.question,
|
|
1185
|
+
answer: q.answer,
|
|
1186
|
+
category: q.category
|
|
1187
|
+
}
|
|
1188
|
+
};
|
|
1189
|
+
});
|
|
1190
|
+
return [...atomic, ...compositional];
|
|
1191
|
+
}
|
|
1192
|
+
_visibleQuestions() {
|
|
1193
|
+
return this._allQuestions().filter((q) => {
|
|
1194
|
+
if (!q.triggerWhen) return true;
|
|
1195
|
+
if (!this.runtime) return true;
|
|
1196
|
+
const result = this.runtime.evaluateSync(q.triggerWhen);
|
|
1197
|
+
return result.value;
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
_orderedQuestions(visible) {
|
|
1201
|
+
if (this.faqConfig.ordering === "priority") {
|
|
1202
|
+
return [...visible].sort((a, b) => (b.config.priority ?? 0) - (a.config.priority ?? 0));
|
|
1203
|
+
}
|
|
1204
|
+
return visible;
|
|
1205
|
+
}
|
|
1206
|
+
_filteredQuestions(ordered) {
|
|
1207
|
+
const q = this._searchQuery.trim().toLowerCase();
|
|
1208
|
+
if (!this.faqConfig.searchable || !q) return ordered;
|
|
1209
|
+
return ordered.filter(
|
|
1210
|
+
(item) => item.config.question.toLowerCase().includes(q) || getAnswerText(item.config.answer).toLowerCase().includes(q) || item.config.category?.toLowerCase().includes(q)
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
1213
|
+
_categoryGroups(filtered) {
|
|
1214
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1215
|
+
for (const item of filtered) {
|
|
1216
|
+
const cat = item.config.category;
|
|
1217
|
+
if (!groups.has(cat)) groups.set(cat, []);
|
|
1218
|
+
groups.get(cat).push(item);
|
|
1219
|
+
}
|
|
1220
|
+
return groups;
|
|
1221
|
+
}
|
|
1222
|
+
// -----------------------------------------------------------------------
|
|
1223
|
+
// Render helpers
|
|
1224
|
+
// -----------------------------------------------------------------------
|
|
1225
|
+
/**
|
|
1226
|
+
* The `data-adaptive-id` scope for this element's injected <style>. When
|
|
1227
|
+
* `instanceId` is left at the shared default (`'faq-widget'`), two tiles would
|
|
1228
|
+
* both scope to `[data-adaptive-id="faq-widget"]` and cross-style each other.
|
|
1229
|
+
* In that case we fall back to a per-element generated uid so each default tile
|
|
1230
|
+
* gets its own scope. A caller-provided instanceId is used verbatim.
|
|
1231
|
+
*/
|
|
1232
|
+
get _scopeId() {
|
|
1233
|
+
if (this.instanceId && this.instanceId !== DEFAULT_INSTANCE_ID) return this.instanceId;
|
|
1234
|
+
if (this._generatedUid === null) {
|
|
1235
|
+
_faqUidCounter += 1;
|
|
1236
|
+
this._generatedUid = `faq-${Date.now().toString(36)}-${_faqUidCounter}`;
|
|
1237
|
+
}
|
|
1238
|
+
return this._generatedUid;
|
|
1239
|
+
}
|
|
1240
|
+
/**
|
|
1241
|
+
* Self-injected flip CSS, scoped to THIS instance (`[data-adaptive-id="…"]`)
|
|
1242
|
+
* so it can't leak onto a sibling widget. The FAQ tile is a light-DOM widget
|
|
1243
|
+
* mounted across several canvases (velvet, default, onflow) and standalone;
|
|
1244
|
+
* velvet ships a GENERIC `[data-active-face]` flip rule (for the product card),
|
|
1245
|
+
* but the other hosts don't — so the FAQ carries the SAME 3D-flip technique,
|
|
1246
|
+
* now from the shared canvas primitive ({@link flipStyles}) rather than a local
|
|
1247
|
+
* copy. The `faq` namespace keeps it on `data-faq-*` / `sc-faq-flip-*` so
|
|
1248
|
+
* velvet's generic rule can't double-apply (the GPU/perspective + resting-flat
|
|
1249
|
+
* contract lives in the primitive). MUST match the FlipController's
|
|
1250
|
+
* `keyframePrefix: 'sc-faq-flip'`.
|
|
1251
|
+
*/
|
|
1252
|
+
_renderFlipStyles() {
|
|
1253
|
+
const scope = `[data-adaptive-id="${this._scopeId}"]`;
|
|
1254
|
+
const css = flipStyles(scope, { ns: "faq", keyframePrefix: "sc-faq-flip" });
|
|
1255
|
+
return html`<style>${css}</style>`;
|
|
1256
|
+
}
|
|
1257
|
+
_renderAnswer(answer) {
|
|
1258
|
+
const html_str = renderAnswerHtml(answer);
|
|
1259
|
+
return html`<div style=${styleMap(sm(baseStyles.detailAnswerClamp))} data-faq-markdown="">${unsafeHTML(html_str)}</div>`;
|
|
1260
|
+
}
|
|
1261
|
+
_renderFeedback(item, feedbackConfig, feedbackValue, theme) {
|
|
1262
|
+
const colors = themeStyles[theme];
|
|
1263
|
+
const feedbackStyle = { ...baseStyles.feedback, ...colors.feedbackPrompt };
|
|
1264
|
+
return html`
|
|
1265
|
+
<div style=${styleMap(sm(feedbackStyle))}>
|
|
1266
|
+
<span>${getFeedbackPrompt(feedbackConfig)}</span>
|
|
1267
|
+
<button
|
|
1268
|
+
type="button"
|
|
1269
|
+
style=${styleMap(
|
|
1270
|
+
sm({
|
|
1271
|
+
...baseStyles.feedbackButton,
|
|
1272
|
+
...feedbackValue === "up" ? baseStyles.feedbackButtonSelected : {}
|
|
1273
|
+
})
|
|
1274
|
+
)}
|
|
1275
|
+
aria-label="Thumbs up"
|
|
1276
|
+
@click=${() => this._handleFeedback(item.config.id, item.config.question, "up")}
|
|
1277
|
+
>\uD83D\uDC4D</button>
|
|
1278
|
+
<button
|
|
1279
|
+
type="button"
|
|
1280
|
+
style=${styleMap(
|
|
1281
|
+
sm({
|
|
1282
|
+
...baseStyles.feedbackButton,
|
|
1283
|
+
...feedbackValue === "down" ? baseStyles.feedbackButtonSelected : {}
|
|
1284
|
+
})
|
|
1285
|
+
)}
|
|
1286
|
+
aria-label="Thumbs down"
|
|
1287
|
+
@click=${() => this._handleFeedback(item.config.id, item.config.question, "down")}
|
|
1288
|
+
>\uD83D\uDC4E</button>
|
|
1289
|
+
</div>
|
|
1290
|
+
`;
|
|
1291
|
+
}
|
|
1292
|
+
/**
|
|
1293
|
+
* LIST-view row. A tappable question with its chevron \u2014 but NO inline answer
|
|
1294
|
+
* body. Tapping it takes over the tile with the question's detail view (see
|
|
1295
|
+
* {@link _renderDetail}). Keeps the per-row look/markers/highlight from the
|
|
1296
|
+
* old accordion minus the inline-expanded answer that overflowed the tile.
|
|
1297
|
+
*/
|
|
1298
|
+
_renderItem(item, isLast, theme) {
|
|
1299
|
+
const colors = themeStyles[theme];
|
|
1300
|
+
const isHighlighted = this._highlightId === item.config.id;
|
|
1301
|
+
const isHovered = this._hoveredId === item.config.id;
|
|
1302
|
+
const itemStyle = {
|
|
1303
|
+
...baseStyles.item,
|
|
1304
|
+
...colors.item,
|
|
1305
|
+
...isHighlighted ? {
|
|
1306
|
+
boxShadow: `0 0 0 2px ${purple[4]}, 0 0 12px rgba(106, 89, 206, 0.4)`,
|
|
1307
|
+
transition: "box-shadow 0.3s ease"
|
|
1308
|
+
} : {},
|
|
1309
|
+
...!isLast ? { borderBottom: "var(--sc-content-item-divider, none)" } : {}
|
|
1310
|
+
};
|
|
1311
|
+
const questionStyle = {
|
|
1312
|
+
...baseStyles.question,
|
|
1313
|
+
...colors.question,
|
|
1314
|
+
...isHovered ? colors.questionHover : {}
|
|
1315
|
+
};
|
|
1316
|
+
const chevronStyle = {
|
|
1317
|
+
...baseStyles.chevron,
|
|
1318
|
+
transform: "rotate(0deg)"
|
|
1319
|
+
};
|
|
1320
|
+
return html`
|
|
1321
|
+
<div
|
|
1322
|
+
style=${styleMap(sm(itemStyle))}
|
|
1323
|
+
data-faq-item-id=${item.config.id}
|
|
1324
|
+
>
|
|
1325
|
+
<button
|
|
1326
|
+
type="button"
|
|
1327
|
+
style=${styleMap(sm(questionStyle))}
|
|
1328
|
+
aria-expanded=${false}
|
|
1329
|
+
@click=${() => this._handleOpen(item.config.id)}
|
|
1330
|
+
@mouseenter=${() => {
|
|
1331
|
+
this._hoveredId = item.config.id;
|
|
1332
|
+
}}
|
|
1333
|
+
@mouseleave=${() => {
|
|
1334
|
+
this._hoveredId = null;
|
|
1335
|
+
}}
|
|
1336
|
+
>
|
|
1337
|
+
<span>${item.config.question}</span>
|
|
1338
|
+
<span style=${styleMap(sm(chevronStyle))}>\u203A</span>
|
|
1339
|
+
</button>
|
|
1340
|
+
</div>
|
|
1341
|
+
`;
|
|
1342
|
+
}
|
|
1343
|
+
_renderItems(items, theme) {
|
|
1344
|
+
return items.map((item, index) => this._renderItem(item, index === items.length - 1, theme));
|
|
1345
|
+
}
|
|
1346
|
+
/**
|
|
1347
|
+
* DETAIL view \u2014 the master\u2192detail takeover. Renders ONLY the open question:
|
|
1348
|
+
* a back affordance, the question prominently, and its full answer via the
|
|
1349
|
+
* SAME {@link _renderAnswer}/`renderAnswerHtml` path (all answer formats still
|
|
1350
|
+
* work). The answer area scrolls if extremely long, so a single answer fills
|
|
1351
|
+
* the bounded velvet tile and never pushes content past the `overflow:hidden`
|
|
1352
|
+
* clip.
|
|
1353
|
+
*/
|
|
1354
|
+
_renderDetail(item, theme, feedbackConfig) {
|
|
1355
|
+
const colors = themeStyles[theme];
|
|
1356
|
+
const backStyle = {
|
|
1357
|
+
...baseStyles.detailBack,
|
|
1358
|
+
...colors.question
|
|
1359
|
+
};
|
|
1360
|
+
const detailQuestionStyle = {
|
|
1361
|
+
...baseStyles.detailQuestion,
|
|
1362
|
+
...colors.question
|
|
1363
|
+
};
|
|
1364
|
+
const detailAnswerStyle = {
|
|
1365
|
+
...baseStyles.detailAnswer,
|
|
1366
|
+
...colors.answer
|
|
1367
|
+
};
|
|
1368
|
+
return html`
|
|
1369
|
+
<div style=${styleMap(sm(baseStyles.detail))} data-faq-detail=${item.config.id}>
|
|
1370
|
+
<button
|
|
1371
|
+
type="button"
|
|
1372
|
+
style=${styleMap(sm(backStyle))}
|
|
1373
|
+
data-faq-back=""
|
|
1374
|
+
aria-label="Back to questions"
|
|
1375
|
+
@click=${() => this._handleBack()}
|
|
1376
|
+
>
|
|
1377
|
+
\u2039 Back to questions
|
|
1378
|
+
</button>
|
|
1379
|
+
|
|
1380
|
+
<div style=${styleMap(sm(detailQuestionStyle))} data-faq-detail-question>${item.config.question}</div>
|
|
1381
|
+
|
|
1382
|
+
<div style=${styleMap(sm(detailAnswerStyle))} aria-hidden=${false}>
|
|
1383
|
+
${this._renderAnswer(item.config.answer)}
|
|
1384
|
+
<div style=${styleMap(sm(baseStyles.detailFooter))}>
|
|
1385
|
+
<button
|
|
1386
|
+
type="button"
|
|
1387
|
+
style=${styleMap(sm(baseStyles.detailDiveDeeper))}
|
|
1388
|
+
data-faq-dive-deeper
|
|
1389
|
+
@click=${() => this._handleDiveDeeper(item)}
|
|
1390
|
+
>Dive deeper ›</button>
|
|
1391
|
+
</div>
|
|
1392
|
+
${feedbackConfig ? this._renderFeedback(
|
|
1393
|
+
item,
|
|
1394
|
+
feedbackConfig,
|
|
1395
|
+
this._feedbackState.get(item.config.id),
|
|
1396
|
+
theme
|
|
1397
|
+
) : nothing}
|
|
1398
|
+
</div>
|
|
1399
|
+
</div>
|
|
1400
|
+
`;
|
|
1401
|
+
}
|
|
1402
|
+
/**
|
|
1403
|
+
* "Dive deeper" (items 4/5) — hand the open question's context back to the
|
|
1404
|
+
* CHAT as a deep-dive turn, so a clamped answer never leaves the visitor
|
|
1405
|
+
* stuck. Canvas-agnostic: dispatches the neutral DIVE_DEEPER_EVENT; the
|
|
1406
|
+
* chat-bar mountable turns it into a user-visible turn. The answer text rides
|
|
1407
|
+
* along as (trimmed) context so the agent grounds its fuller reply.
|
|
1408
|
+
*/
|
|
1409
|
+
_handleDiveDeeper(item) {
|
|
1410
|
+
const question = item.config.question;
|
|
1411
|
+
const answerText = getAnswerText(item.config.answer);
|
|
1412
|
+
dispatchDiveDeeper({
|
|
1413
|
+
// State an UNMET NEED — never re-ask the question (BUG-1784153527).
|
|
1414
|
+
// `Tell me more about: ${question}` re-asked the very question whose
|
|
1415
|
+
// answer was already on screen, so the agent just answered it again
|
|
1416
|
+
// instead of asking what was missing. The question rides in `title` +
|
|
1417
|
+
// the turn-origin envelope.
|
|
1418
|
+
prompt: "I'd like to dive deeper on this answer, I didn't see what I needed.",
|
|
1419
|
+
title: question,
|
|
1420
|
+
context: answerText ? answerText.slice(0, 500) : void 0
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1423
|
+
// -----------------------------------------------------------------------
|
|
1424
|
+
// Render
|
|
1425
|
+
// -----------------------------------------------------------------------
|
|
1426
|
+
render() {
|
|
1427
|
+
const theme = resolveTheme(this.faqConfig.theme);
|
|
1428
|
+
const colors = themeStyles[theme];
|
|
1429
|
+
const feedbackConfig = resolveFeedbackConfig(this.faqConfig.feedback);
|
|
1430
|
+
const visible = this._visibleQuestions();
|
|
1431
|
+
const ordered = this._orderedQuestions(visible);
|
|
1432
|
+
const filtered = this._filteredQuestions(ordered);
|
|
1433
|
+
const hasCategories = filtered.some((q) => q.config.category);
|
|
1434
|
+
const groups = hasCategories ? this._categoryGroups(filtered) : null;
|
|
1435
|
+
const containerStyle = {
|
|
1436
|
+
...baseStyles.container,
|
|
1437
|
+
...colors.container
|
|
1438
|
+
};
|
|
1439
|
+
const emptyStateStyle = {
|
|
1440
|
+
...baseStyles.emptyState,
|
|
1441
|
+
...colors.emptyState
|
|
1442
|
+
};
|
|
1443
|
+
const categoryHeaderStyle = {
|
|
1444
|
+
...baseStyles.categoryHeader,
|
|
1445
|
+
...colors.categoryHeader
|
|
1446
|
+
};
|
|
1447
|
+
const searchInputStyle = {
|
|
1448
|
+
...baseStyles.searchInput,
|
|
1449
|
+
...colors.searchInput
|
|
1450
|
+
};
|
|
1451
|
+
if (visible.length === 0) {
|
|
1452
|
+
if (this.runtime) return nothing;
|
|
1453
|
+
return html`
|
|
1454
|
+
<div
|
|
1455
|
+
style=${styleMap(sm(containerStyle))}
|
|
1456
|
+
data-adaptive-id=${this._scopeId}
|
|
1457
|
+
data-adaptive-type="adaptive-faq"
|
|
1458
|
+
>
|
|
1459
|
+
<div style=${styleMap(sm(emptyStateStyle))}>
|
|
1460
|
+
You're all set for now! We'll surface answers here when they're relevant to what
|
|
1461
|
+
you're doing.
|
|
1462
|
+
</div>
|
|
1463
|
+
</div>
|
|
1464
|
+
`;
|
|
1465
|
+
}
|
|
1466
|
+
const openItem = this._openId !== null ? ordered.find((q) => q.config.id === this._openId) : void 0;
|
|
1467
|
+
const activeFace = openItem ? this._activeFace : "front";
|
|
1468
|
+
const backActive = activeFace === "back";
|
|
1469
|
+
const flipAnimating = this._flipAnimating;
|
|
1470
|
+
return html`
|
|
1471
|
+
<div
|
|
1472
|
+
style=${styleMap(sm(containerStyle))}
|
|
1473
|
+
data-adaptive-id=${this._scopeId}
|
|
1474
|
+
data-adaptive-type="adaptive-faq"
|
|
1475
|
+
>
|
|
1476
|
+
${this._renderFlipStyles()}
|
|
1477
|
+
<div data-faq-flip-viewport ?data-faq-flip-animating=${flipAnimating}>
|
|
1478
|
+
<div
|
|
1479
|
+
data-faq-flip
|
|
1480
|
+
data-faq-active-face=${activeFace}
|
|
1481
|
+
?data-faq-flip-animating=${flipAnimating}
|
|
1482
|
+
data-faq-flip-dir=${flipAnimating ? this._flip.dir : nothing}
|
|
1483
|
+
@animationend=${this._flip.onAnimationEnd}
|
|
1484
|
+
>
|
|
1485
|
+
<div data-face="front" ?inert=${backActive} aria-hidden=${backActive}>
|
|
1486
|
+
${this._renderListFace(
|
|
1487
|
+
groups,
|
|
1488
|
+
filtered,
|
|
1489
|
+
theme,
|
|
1490
|
+
searchInputStyle,
|
|
1491
|
+
categoryHeaderStyle,
|
|
1492
|
+
{
|
|
1493
|
+
...baseStyles.noResults,
|
|
1494
|
+
...colors.emptyState
|
|
1495
|
+
}
|
|
1496
|
+
)}
|
|
1497
|
+
</div>
|
|
1498
|
+
<div data-face="back" ?inert=${!backActive} aria-hidden=${!backActive}>
|
|
1499
|
+
${openItem ? this._renderDetail(openItem, theme, feedbackConfig) : nothing}
|
|
1500
|
+
</div>
|
|
1501
|
+
</div>
|
|
1502
|
+
</div>
|
|
1503
|
+
</div>
|
|
1504
|
+
`;
|
|
1505
|
+
}
|
|
1506
|
+
/** The FRONT face — search box, the question list (optionally grouped by
|
|
1507
|
+
* category), and the no-results message. Extracted so both faces can live in
|
|
1508
|
+
* the DOM at once for the flip. */
|
|
1509
|
+
_renderListFace(groups, filtered, theme, searchInputStyle, categoryHeaderStyle, noResultsStyle) {
|
|
1510
|
+
return html`
|
|
1511
|
+
${this.faqConfig.searchable ? html`
|
|
1512
|
+
<div style=${styleMap(sm(baseStyles.searchWrapper))}>
|
|
1513
|
+
<style>
|
|
1514
|
+
[data-adaptive-id="${this._scopeId}"] input::placeholder {
|
|
1515
|
+
color: var(--sc-content-search-color, inherit);
|
|
1516
|
+
opacity: 0.7;
|
|
1517
|
+
}
|
|
1518
|
+
</style>
|
|
1519
|
+
<input
|
|
1520
|
+
type="text"
|
|
1521
|
+
placeholder="Search questions..."
|
|
1522
|
+
.value=${this._searchQuery}
|
|
1523
|
+
style=${styleMap(sm(searchInputStyle))}
|
|
1524
|
+
@input=${(e) => {
|
|
1525
|
+
this._searchQuery = e.target.value;
|
|
1526
|
+
}}
|
|
1527
|
+
/>
|
|
1528
|
+
</div>
|
|
1529
|
+
` : nothing}
|
|
1530
|
+
|
|
1531
|
+
<div style=${styleMap(sm(baseStyles.accordion))}>
|
|
1532
|
+
${groups ? Array.from(groups.entries()).map(
|
|
1533
|
+
([category, items]) => html`
|
|
1534
|
+
${category ? html`
|
|
1535
|
+
<div
|
|
1536
|
+
style=${styleMap(sm(categoryHeaderStyle))}
|
|
1537
|
+
data-category-header=${category}
|
|
1538
|
+
>
|
|
1539
|
+
${category}
|
|
1540
|
+
</div>
|
|
1541
|
+
` : nothing}
|
|
1542
|
+
${this._renderItems(items, theme)}
|
|
1543
|
+
`
|
|
1544
|
+
) : this._renderItems(filtered, theme)}
|
|
1545
|
+
</div>
|
|
1546
|
+
|
|
1547
|
+
${this.faqConfig.searchable && filtered.length === 0 && this._searchQuery ? html`
|
|
1548
|
+
<div style=${styleMap(sm(noResultsStyle))}>
|
|
1549
|
+
No questions found matching "${this._searchQuery}"
|
|
1550
|
+
</div>
|
|
1551
|
+
` : nothing}
|
|
1552
|
+
`;
|
|
1553
|
+
}
|
|
1554
|
+
};
|
|
1555
|
+
_FAQAccordionElement_instances = new WeakSet();
|
|
1556
|
+
/** Measure only the SETTLED accordion (at least one visible question). An
|
|
1557
|
+
* empty accordion renders `nothing` (runtime) or a reassurance box (no
|
|
1558
|
+
* runtime) and is never "blank". NON-PII stateKey. */
|
|
1559
|
+
healthProbe_fn = function() {
|
|
1560
|
+
const visible = this._visibleQuestions();
|
|
1561
|
+
if (visible.length === 0) return null;
|
|
1562
|
+
const root = this.querySelector('[data-adaptive-type="adaptive-faq"]');
|
|
1563
|
+
if (!root) return null;
|
|
1564
|
+
return {
|
|
1565
|
+
root,
|
|
1566
|
+
stateKey: `faq|${visible.length}|${this._activeFace}`,
|
|
1567
|
+
category: "faq_accordion",
|
|
1568
|
+
widgetKind: "adaptive-faq:accordion"
|
|
1569
|
+
};
|
|
1570
|
+
};
|
|
1571
|
+
// -----------------------------------------------------------------------
|
|
1572
|
+
// Reactive properties (no decorators — tsconfig forbids experimentalDecorators)
|
|
1573
|
+
// -----------------------------------------------------------------------
|
|
1574
|
+
FAQAccordionElement.properties = {
|
|
1575
|
+
// Public API — set from the outside
|
|
1576
|
+
faqConfig: { attribute: false },
|
|
1577
|
+
runtime: { attribute: false },
|
|
1578
|
+
instanceId: { type: String },
|
|
1579
|
+
// Internal reactive state (prefixed with _ to signal "private").
|
|
1580
|
+
// The flip state machine — open item, active face, animating flag, direction,
|
|
1581
|
+
// and the token-coupled settle timer — lives in a shared FlipController (see
|
|
1582
|
+
// {@link _flip}); the controller drives re-renders via host.requestUpdate().
|
|
1583
|
+
// `_openId` / `_activeFace` / `_flipAnimating` / `_flipDir` are exposed as
|
|
1584
|
+
// getters over that controller (below) so the render + tests read the same
|
|
1585
|
+
// state without duplicating the machinery.
|
|
1586
|
+
_highlightId: { state: true },
|
|
1587
|
+
_searchQuery: { state: true },
|
|
1588
|
+
_feedbackState: { state: true },
|
|
1589
|
+
_hoveredId: { state: true }
|
|
1590
|
+
};
|
|
1591
|
+
if (!customElements.get("syntro-faq-accordion")) {
|
|
1592
|
+
customElements.define("syntro-faq-accordion", FAQAccordionElement);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
// src/runtime.ts
|
|
1596
|
+
var DEFAULT_FAQ_CONFIG = {
|
|
1597
|
+
expandBehavior: "single",
|
|
1598
|
+
searchable: false,
|
|
1599
|
+
theme: "auto",
|
|
1600
|
+
actions: []
|
|
1601
|
+
};
|
|
1602
|
+
var FAQWidgetLitMountable = {
|
|
1603
|
+
mount(container, config) {
|
|
1604
|
+
const incoming = config ?? null;
|
|
1605
|
+
const stripped = stripMountPlumbing(incoming);
|
|
1606
|
+
const runtime2 = incoming?.runtime;
|
|
1607
|
+
const instanceId = incoming?.instanceId ?? "faq-widget";
|
|
1608
|
+
const faqConfig = incoming ? stripped : { ...DEFAULT_FAQ_CONFIG };
|
|
1609
|
+
const el = document.createElement("syntro-faq-accordion");
|
|
1610
|
+
Object.assign(el, {
|
|
1611
|
+
faqConfig,
|
|
1612
|
+
runtime: runtime2 ?? null,
|
|
1613
|
+
instanceId
|
|
1614
|
+
});
|
|
1615
|
+
container.appendChild(el);
|
|
1616
|
+
return () => el.remove();
|
|
1617
|
+
}
|
|
1618
|
+
};
|
|
1619
|
+
var runtime = {
|
|
1620
|
+
id: "adaptive-faq",
|
|
1621
|
+
version: "2.0.0",
|
|
1622
|
+
name: "FAQ Accordion",
|
|
1623
|
+
description: "Collapsible Q&A accordion with actions, rich content, feedback, and personalization",
|
|
1624
|
+
/**
|
|
1625
|
+
* Action executors for programmatic FAQ interaction.
|
|
1626
|
+
*/
|
|
1627
|
+
executors: executorDefinitions,
|
|
1628
|
+
/**
|
|
1629
|
+
* Widget definitions for the runtime's WidgetRegistry.
|
|
1630
|
+
*/
|
|
1631
|
+
widgets: [
|
|
1632
|
+
{
|
|
1633
|
+
id: "adaptive-faq:accordion",
|
|
1634
|
+
component: FAQWidgetLitMountable,
|
|
1635
|
+
metadata: {
|
|
1636
|
+
name: "FAQ Accordion",
|
|
1637
|
+
description: "Collapsible Q&A accordion with search, categories, and feedback",
|
|
1638
|
+
icon: "\u2753",
|
|
1639
|
+
subtitle: "Curated just for you."
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
],
|
|
1643
|
+
/**
|
|
1644
|
+
* Extract notify watcher entries from tile config props.
|
|
1645
|
+
* The runtime evaluates these continuously (even with drawer closed)
|
|
1646
|
+
* and publishes faq:question_revealed when triggerWhen transitions false → true.
|
|
1647
|
+
*/
|
|
1648
|
+
notifyWatchers(props) {
|
|
1649
|
+
const actions = props.actions ?? [];
|
|
1650
|
+
return actions.filter((a) => a.notify && a.triggerWhen).map((a) => ({
|
|
1651
|
+
id: `faq:${a.config.id}`,
|
|
1652
|
+
strategy: a.triggerWhen,
|
|
1653
|
+
eventName: "faq:question_revealed",
|
|
1654
|
+
eventProps: {
|
|
1655
|
+
questionId: a.config.id,
|
|
1656
|
+
question: a.config.question,
|
|
1657
|
+
title: a.notify.title,
|
|
1658
|
+
body: a.notify.body,
|
|
1659
|
+
icon: a.notify.icon
|
|
1660
|
+
}
|
|
1661
|
+
}));
|
|
1662
|
+
}
|
|
1663
|
+
};
|
|
1664
|
+
var runtime_default = runtime;
|
|
1665
|
+
|
|
1666
|
+
export {
|
|
1667
|
+
FAQWidgetLitMountable,
|
|
1668
|
+
runtime,
|
|
1669
|
+
runtime_default
|
|
1670
|
+
};
|
|
1671
|
+
//# sourceMappingURL=chunk-AJELMQH5.js.map
|