@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.
@@ -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 &quot;${this._searchQuery}&quot;
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