@blotoutio/providers-evo-search-sdk 1.65.2 → 1.66.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/core.cjs.js CHANGED
@@ -1244,6 +1244,7 @@ const init = (params) => {
1244
1244
  const tag = ({ data }) => {
1245
1245
  try {
1246
1246
  return {
1247
+ sdkVersion: "1.66.0" || '',
1247
1248
  searchQuery: data['searchQuery'],
1248
1249
  searchResults: data['searchResults'],
1249
1250
  timestamp: Date.now(),
@@ -1252,6 +1253,7 @@ const tag = ({ data }) => {
1252
1253
  catch (err) {
1253
1254
  console.error(`EvoSearch SDK: Tagging failed - ${err}`);
1254
1255
  return {
1256
+ sdkVersion: "1.66.0" ,
1255
1257
  timestamp: Date.now(),
1256
1258
  };
1257
1259
  }
package/core.js CHANGED
@@ -1245,6 +1245,7 @@ var ProvidersEvoSearchSdk = (function () {
1245
1245
  const tag = ({ data }) => {
1246
1246
  try {
1247
1247
  return {
1248
+ sdkVersion: "1.66.0" || '',
1248
1249
  searchQuery: data['searchQuery'],
1249
1250
  searchResults: data['searchResults'],
1250
1251
  timestamp: Date.now(),
@@ -1253,6 +1254,7 @@ var ProvidersEvoSearchSdk = (function () {
1253
1254
  catch (err) {
1254
1255
  console.error(`EvoSearch SDK: Tagging failed - ${err}`);
1255
1256
  return {
1257
+ sdkVersion: "1.66.0" ,
1256
1258
  timestamp: Date.now(),
1257
1259
  };
1258
1260
  }
package/core.mjs CHANGED
@@ -1242,6 +1242,7 @@ const init = (params) => {
1242
1242
  const tag = ({ data }) => {
1243
1243
  try {
1244
1244
  return {
1245
+ sdkVersion: "1.66.0" || '',
1245
1246
  searchQuery: data['searchQuery'],
1246
1247
  searchResults: data['searchResults'],
1247
1248
  timestamp: Date.now(),
@@ -1250,6 +1251,7 @@ const tag = ({ data }) => {
1250
1251
  catch (err) {
1251
1252
  console.error(`EvoSearch SDK: Tagging failed - ${err}`);
1252
1253
  return {
1254
+ sdkVersion: "1.66.0" ,
1253
1255
  timestamp: Date.now(),
1254
1256
  };
1255
1257
  }
package/hooks.d.ts CHANGED
@@ -56,6 +56,12 @@ declare module '@blotoutio/providers-evo-search-sdk/hooks' {
56
56
  llmSuggestions: string[]
57
57
  products: AutocompleteAPIProduct[]
58
58
  collections?: AutocompleteAPICollection[]
59
+ signals?: {
60
+ bm25: boolean
61
+ ann: boolean
62
+ rerank: boolean
63
+ degraded: boolean
64
+ }
59
65
  }
60
66
 
61
67
  export type RecentsAPIResponse = {
@@ -65,6 +71,8 @@ declare module '@blotoutio/providers-evo-search-sdk/hooks' {
65
71
  export type AskConfigResponse = {
66
72
  askAiEnabled: boolean
67
73
  askStarters?: string[]
74
+ askNudge: 'always' | 'weak' | 'never'
75
+ askNudgeLabel: string
68
76
  brandName?: string
69
77
  brandVoice?: string
70
78
  storeDescription?: string
package/index.cjs.js CHANGED
@@ -1,5 +1,25 @@
1
1
  'use strict';
2
2
 
3
+ // When the Search tab offers Ask AI alongside its results. `always`: on every
4
+ // result set (the default — the tab pill alone doesn't read as an offer).
5
+ // `weak`: only when retrieval had no lexical anchor, i.e. the response's
6
+ // `signals.bm25` is false, so the products on screen are a semantic guess.
7
+ // `never`: the search results never mention Ask AI (the zero-result recovery
8
+ // CTA is unaffected — that one is a dead end, not a nudge).
9
+ // The tuple is the single source of truth: the API payload enum, the worker's
10
+ // write validator and the manifest coercion all derive from it, so adding a
11
+ // mode can't leave one layer rejecting it.
12
+ // Default on: the segmented tab pill alone doesn't read as an offer, which is
13
+ // the whole reason the nudge exists. Merchants dial it back to 'weak'/'never'.
14
+ const DEFAULT_ASK_NUDGE_MODE = 'always';
15
+ // Merchant-authored label for the nudge. `{query}` is substituted with the
16
+ // shopper's search terms; a label without the token renders verbatim, which is
17
+ // the escape hatch for merchants who don't want the query echoed back.
18
+ // Substitution happens through Lit text bindings, so the query is escaped —
19
+ // never interpolate this into markup.
20
+ const ASK_NUDGE_QUERY_TOKEN = '{query}';
21
+ const DEFAULT_ASK_NUDGE_LABEL = `Ask AI about ${ASK_NUDGE_QUERY_TOKEN}`;
22
+
3
23
  /**
4
24
  * Known ad-network click ID query parameters and the human-readable provider
5
25
  * label each one maps to. Used by the CDN worker to resolve `inSessionTouch`
@@ -630,7 +650,7 @@ const setStoredSearchMode = (mode) => {
630
650
  const searchSessionKeyName = 'evoSearchSession';
631
651
  const SEARCH_SESSION_VERSION = 1;
632
652
  const getStoredSearchSession = () => {
633
- var _a, _b, _c, _d, _e, _f, _g;
653
+ var _a, _b, _c, _d, _e, _f, _g, _h;
634
654
  try {
635
655
  const raw = sessionStorage.getItem(searchSessionKeyName);
636
656
  if (!raw) {
@@ -652,10 +672,12 @@ const getStoredSearchSession = () => {
652
672
  suggestions: (_a = parsed.suggestions) !== null && _a !== void 0 ? _a : [],
653
673
  products: (_b = parsed.products) !== null && _b !== void 0 ? _b : [],
654
674
  collections: (_c = parsed.collections) !== null && _c !== void 0 ? _c : [],
655
- askAnswer: (_d = parsed.askAnswer) !== null && _d !== void 0 ? _d : '',
656
- askEvidence: (_e = parsed.askEvidence) !== null && _e !== void 0 ? _e : null,
657
- askFollowUps: (_f = parsed.askFollowUps) !== null && _f !== void 0 ? _f : [],
658
- askCitedTitles: (_g = parsed.askCitedTitles) !== null && _g !== void 0 ? _g : [],
675
+ // Absent on pre-nudge snapshots — true matches the widget's own default.
676
+ hadLexicalMatch: (_d = parsed.hadLexicalMatch) !== null && _d !== void 0 ? _d : true,
677
+ askAnswer: (_e = parsed.askAnswer) !== null && _e !== void 0 ? _e : '',
678
+ askEvidence: (_f = parsed.askEvidence) !== null && _f !== void 0 ? _f : null,
679
+ askFollowUps: (_g = parsed.askFollowUps) !== null && _g !== void 0 ? _g : [],
680
+ askCitedTitles: (_h = parsed.askCitedTitles) !== null && _h !== void 0 ? _h : [],
659
681
  };
660
682
  }
661
683
  catch {
@@ -1429,6 +1451,7 @@ const init = (params) => {
1429
1451
  const tag = ({ data }) => {
1430
1452
  try {
1431
1453
  return {
1454
+ sdkVersion: "1.66.0" || '',
1432
1455
  searchQuery: data['searchQuery'],
1433
1456
  searchResults: data['searchResults'],
1434
1457
  timestamp: Date.now(),
@@ -1437,6 +1460,7 @@ const tag = ({ data }) => {
1437
1460
  catch (err) {
1438
1461
  console.error(`EvoSearch SDK: Tagging failed - ${err}`);
1439
1462
  return {
1463
+ sdkVersion: "1.66.0" ,
1440
1464
  timestamp: Date.now(),
1441
1465
  };
1442
1466
  }
@@ -7566,12 +7590,12 @@ const stateBlockStyles = i$6 `
7566
7590
 
7567
7591
  /* The AI rescue is the primary recovery on a dead-end search. */
7568
7592
  .chip-ask {
7569
- background: var(--evosearch-ask-accent, #2563eb);
7593
+ background: var(--evosearch-ask-accent, #7c3aed);
7570
7594
  color: #ffffff;
7571
7595
  }
7572
7596
 
7573
7597
  .chip-ask:hover {
7574
- background: var(--evosearch-ask-accent, #2563eb);
7598
+ background: var(--evosearch-ask-accent, #7c3aed);
7575
7599
  filter: brightness(0.95);
7576
7600
  }
7577
7601
 
@@ -7840,7 +7864,7 @@ const searchWidgetStyles = i$6 `
7840
7864
  /* Ask mode swaps the active accent; everything inside the container reads
7841
7865
  var(--evosearch-mode-accent) (custom props inherit to evs-box + cards). */
7842
7866
  .search-widget-container[data-mode='ask'] {
7843
- --evosearch-mode-accent: var(--evosearch-ask-accent, #2563eb);
7867
+ --evosearch-mode-accent: var(--evosearch-ask-accent, #7c3aed);
7844
7868
  }
7845
7869
 
7846
7870
  /* The theme font reaches form controls (which don't inherit it by default). */
@@ -8090,6 +8114,12 @@ const searchWidgetStyles = i$6 `
8090
8114
  max-height: 0;
8091
8115
  opacity: 0;
8092
8116
  pointer-events: none;
8117
+ /* The collapsed row is still a .body child, so the sibling rule above gives
8118
+ it a top margin AND another to whatever follows — 2.25em of dead space
8119
+ stacked around a zero-height element, which reads as a broken gap between
8120
+ the nudge and the results heading. Drop its own margin so the only gap
8121
+ left is the single one the next section is entitled to. */
8122
+ margin-top: 0;
8093
8123
  }
8094
8124
 
8095
8125
  /* inline-block (not flex) so the typed span and the bold completion stay on
@@ -8217,9 +8247,6 @@ const searchWidgetStyles = i$6 `
8217
8247
 
8218
8248
  /* Segmented Results/AI toggle — replaces the "Search with AI" CTA once an
8219
8249
  answer is in memory, letting the shopper flip between the two views. */
8220
- /* Dual-tone segmented control: two equal halves with a thumb that slides
8221
- between them (driven by the container's data-mode). The thumb is neutral
8222
- under Search and gains an amethyst→indigo wash under Ask AI. */
8223
8250
  /* Compact segmented control living inside the search bar. Two equal halves
8224
8251
  (grid 1fr 1fr) sized to their content so the sliding thumb stays aligned
8225
8252
  at 50%; flex:0 0 auto keeps it from stretching in the header row. */
@@ -8271,13 +8298,17 @@ const searchWidgetStyles = i$6 `
8271
8298
  .search-widget-container[data-mode='ask'] .view-toggle::before {
8272
8299
  transform: translateX(100%);
8273
8300
  border-radius: 0 999px 999px 0;
8274
- background: linear-gradient(
8275
- 135deg,
8276
- rgba(147, 51, 234, 0.12),
8277
- rgba(99, 102, 241, 0.1)
8278
- ),
8279
- #ffffff;
8280
- box-shadow: 0 1px 4px rgba(76, 29, 149, 0.2),
8301
+ background: color-mix(
8302
+ in srgb,
8303
+ var(--evosearch-ask-accent, #7c3aed) 10%,
8304
+ #ffffff
8305
+ );
8306
+ box-shadow: 0 1px 4px
8307
+ color-mix(
8308
+ in srgb,
8309
+ var(--evosearch-ask-accent, #7c3aed) 22%,
8310
+ transparent
8311
+ ),
8281
8312
  inset 0 -0.5px 0.5px rgba(10, 13, 18, 0.08);
8282
8313
  }
8283
8314
 
@@ -8313,11 +8344,13 @@ const searchWidgetStyles = i$6 `
8313
8344
  }
8314
8345
 
8315
8346
  .view-toggle-search:focus-visible {
8316
- box-shadow: 0 0 0 3px rgba(234, 88, 12, 0.3);
8347
+ box-shadow: 0 0 0 3px
8348
+ color-mix(in srgb, var(--evosearch-accent, #e0590b) 30%, transparent);
8317
8349
  }
8318
8350
 
8319
8351
  .view-toggle-ai:focus-visible {
8320
- box-shadow: 0 0 0 3px rgba(147, 51, 234, 0.3);
8352
+ box-shadow: 0 0 0 3px
8353
+ color-mix(in srgb, var(--evosearch-ask-accent, #7c3aed) 30%, transparent);
8321
8354
  }
8322
8355
 
8323
8356
  .view-toggle-search:hover:not(.is-active) {
@@ -8325,16 +8358,19 @@ const searchWidgetStyles = i$6 `
8325
8358
  }
8326
8359
 
8327
8360
  .view-toggle-ai:hover:not(.is-active) {
8328
- color: #9333ea;
8361
+ color: var(--evosearch-ask-accent, #7c3aed);
8329
8362
  }
8330
8363
 
8331
- /* Search vs Ask each carry their own accent when active — one coherent system. */
8364
+ /* Each half carries its own accent when active: Search the brand accent, Ask
8365
+ the --evosearch-ask-accent that state-block, the ask-answer rule and the
8366
+ follow-up chips already use — so one colour means "AI" everywhere rather
8367
+ than the toggle inventing a second one. */
8332
8368
  .view-toggle-search.is-active {
8333
- color: var(--evosearch-accent, #ea580c);
8369
+ color: var(--evosearch-accent, #e0590b);
8334
8370
  }
8335
8371
 
8336
8372
  .view-toggle-ai.is-active {
8337
- color: #9333ea;
8373
+ color: var(--evosearch-ask-accent, #7c3aed);
8338
8374
  }
8339
8375
 
8340
8376
  .view-toggle-btn bt-lucide-icon,
@@ -8343,7 +8379,7 @@ const searchWidgetStyles = i$6 `
8343
8379
  }
8344
8380
 
8345
8381
  .ask-answer {
8346
- border-left: 3px solid var(--evosearch-mode-accent, #2563eb);
8382
+ border-left: 3px solid var(--evosearch-mode-accent, #7c3aed);
8347
8383
  padding: 0 0 0 0.75em;
8348
8384
  line-height: 1.5;
8349
8385
  min-height: 1.5em;
@@ -8399,8 +8435,8 @@ const searchWidgetStyles = i$6 `
8399
8435
 
8400
8436
  .ask-starter-chip:hover {
8401
8437
  background: #fcfdfb;
8402
- border-color: var(--evosearch-ask-accent, #2563eb);
8403
- color: var(--evosearch-ask-accent, #2563eb);
8438
+ border-color: var(--evosearch-ask-accent, #7c3aed);
8439
+ color: var(--evosearch-ask-accent, #7c3aed);
8404
8440
  }
8405
8441
 
8406
8442
  .ask-starter-chip:active {
@@ -8498,6 +8534,89 @@ const searchWidgetStyles = i$6 `
8498
8534
  color: rgba(26, 28, 24, 0.6);
8499
8535
  }
8500
8536
 
8537
+ /* The Ask AI offer: a filled band leading the results column, directly under
8538
+ the search bar. It is the first thing in .body, so it is read before the
8539
+ grid is scanned rather than after it disappoints — and it can never be
8540
+ stranded in whitespace under a short result row. */
8541
+ .ask-nudge {
8542
+ appearance: none;
8543
+ display: flex;
8544
+ align-items: center;
8545
+ gap: 0.5em;
8546
+ width: 100%;
8547
+ padding: 0.625em 0.875em;
8548
+ border: 1px solid
8549
+ color-mix(in srgb, var(--evosearch-ask-accent, #7c3aed) 28%, transparent);
8550
+ border-radius: 0.625em;
8551
+ background: color-mix(
8552
+ in srgb,
8553
+ var(--evosearch-ask-accent, #7c3aed) 6%,
8554
+ #ffffff
8555
+ );
8556
+ color: var(--evosearch-ask-accent, #7c3aed);
8557
+ font-size: 0.8125em;
8558
+ font-weight: 600;
8559
+ text-align: left;
8560
+ cursor: pointer;
8561
+ transition: background 0.15s ease, border-color 0.15s ease;
8562
+ }
8563
+
8564
+ .ask-nudge:hover {
8565
+ background: color-mix(
8566
+ in srgb,
8567
+ var(--evosearch-ask-accent, #7c3aed) 11%,
8568
+ #ffffff
8569
+ );
8570
+ border-color: color-mix(
8571
+ in srgb,
8572
+ var(--evosearch-ask-accent, #7c3aed) 42%,
8573
+ transparent
8574
+ );
8575
+ }
8576
+
8577
+ /* The label takes the slack so the trailing arrow sits hard right, and a
8578
+ merchant-authored label that outruns the band ellipsises instead of
8579
+ wrapping it onto a third line. */
8580
+ .ask-nudge-text {
8581
+ flex: 1 1 auto;
8582
+ min-width: 0;
8583
+ overflow: hidden;
8584
+ text-overflow: ellipsis;
8585
+ white-space: nowrap;
8586
+ }
8587
+
8588
+ @supports (background-clip: text) or (-webkit-background-clip: text) {
8589
+ .ask-nudge-text {
8590
+ background: linear-gradient(
8591
+ 100deg,
8592
+ var(--evosearch-ask-accent, #7c3aed),
8593
+ var(--evosearch-ask-accent-alt, #6366f1)
8594
+ );
8595
+ -webkit-background-clip: text;
8596
+ background-clip: text;
8597
+ color: transparent;
8598
+ }
8599
+ }
8600
+
8601
+ /* Amethyst→indigo sweep on the label so the offer reads as generative rather
8602
+ than as a plain link. Guarded by @supports because the fallback for an
8603
+ unsupported background-clip is transparent text, i.e. an invisible CTA —
8604
+ outside the query the solid ask accent from .ask-nudge stands. The sparkle
8605
+ and arrow icons keep currentColor: gradient-filling icon strokes at 14px
8606
+ muddies them, and the solid accent still reads as the same family. */
8607
+ @supports (background-clip: text) or (-webkit-background-clip: text) {
8608
+ .ask-nudge > span {
8609
+ background: linear-gradient(
8610
+ 100deg,
8611
+ var(--evosearch-ask-accent, #7c3aed),
8612
+ var(--evosearch-ask-accent-alt, #6366f1)
8613
+ );
8614
+ -webkit-background-clip: text;
8615
+ background-clip: text;
8616
+ color: transparent;
8617
+ }
8618
+ }
8619
+
8501
8620
  .ask-followups {
8502
8621
  display: flex;
8503
8622
  flex-wrap: wrap;
@@ -8523,6 +8642,7 @@ const searchWidgetStyles = i$6 `
8523
8642
  .chip:focus-visible,
8524
8643
  .chip-remove:focus-visible,
8525
8644
  .suggestion:focus-visible,
8645
+ .ask-nudge:focus-visible,
8526
8646
  .view-toggle-btn:focus-visible,
8527
8647
  .bar-dismiss:focus-visible,
8528
8648
  .ask-starter-chip:focus-visible,
@@ -8539,7 +8659,7 @@ const searchWidgetStyles = i$6 `
8539
8659
  gap: 0.45em;
8540
8660
  border: 0;
8541
8661
  border-radius: 999px;
8542
- background: var(--evosearch-ask-accent, #2563eb);
8662
+ background: var(--evosearch-ask-accent, #7c3aed);
8543
8663
  color: #ffffff;
8544
8664
  font-size: 0.8125em;
8545
8665
  font-weight: 600;
@@ -8635,6 +8755,11 @@ class SearchWidget extends ElementWithStylesheet {
8635
8755
  this.suggestions = [];
8636
8756
  this.searchProducts = [];
8637
8757
  this.searchCollections = [];
8758
+ // Whether the current results had a lexical (BM25) anchor. False means the
8759
+ // grid is a purely semantic guess, which is what `askNudge: 'weak'` targets.
8760
+ // Defaults true so a worker that omits `signals` is treated as a normal
8761
+ // match rather than silently nudging on every query.
8762
+ this.hadLexicalMatch = true;
8638
8763
  this.recents = [];
8639
8764
  this.loading = false;
8640
8765
  this.hasError = false;
@@ -9217,7 +9342,7 @@ class SearchWidget extends ElementWithStylesheet {
9217
9342
  }));
9218
9343
  }
9219
9344
  async performAutocomplete(query) {
9220
- var _a;
9345
+ var _a, _b;
9221
9346
  const generation = ++this.requestGeneration;
9222
9347
  this.hasError = false;
9223
9348
  try {
@@ -9237,6 +9362,7 @@ class SearchWidget extends ElementWithStylesheet {
9237
9362
  this.suggestions = processed.suggestions;
9238
9363
  this.searchProducts = processed.products;
9239
9364
  this.searchCollections = (_a = response.collections) !== null && _a !== void 0 ? _a : [];
9365
+ this.hadLexicalMatch = ((_b = response.signals) === null || _b === void 0 ? void 0 : _b.bm25) !== false;
9240
9366
  this.debouncedQuery = query;
9241
9367
  this.loading = false;
9242
9368
  if (this.finalizeOnRender) {
@@ -9376,6 +9502,7 @@ class SearchWidget extends ElementWithStylesheet {
9376
9502
  this.showSuggestions = false;
9377
9503
  this.searchProducts = [];
9378
9504
  this.searchCollections = [];
9505
+ this.hadLexicalMatch = true;
9379
9506
  this.activeSuggestionIndex = -1;
9380
9507
  // Ask mode renders from a separate state set, so clearing the query alone
9381
9508
  // leaves the previous answer/evidence on screen. Reset it too and abort any
@@ -9477,6 +9604,7 @@ class SearchWidget extends ElementWithStylesheet {
9477
9604
  suggestions: this.suggestions,
9478
9605
  products: this.searchProducts,
9479
9606
  collections: this.searchCollections,
9607
+ hadLexicalMatch: this.hadLexicalMatch,
9480
9608
  askAnswer: this.askAnswer,
9481
9609
  askEvidence: this.askEvidence,
9482
9610
  askFollowUps: this.askFollowUps,
@@ -9497,6 +9625,9 @@ class SearchWidget extends ElementWithStylesheet {
9497
9625
  this.suggestions = snap.suggestions;
9498
9626
  this.searchProducts = snap.products;
9499
9627
  this.searchCollections = snap.collections;
9628
+ // Restored too, or a purely semantic result set would look like a lexical
9629
+ // match after a reopen and wrongly suppress the `weak` nudge.
9630
+ this.hadLexicalMatch = snap.hadLexicalMatch;
9500
9631
  // The restored query was already a real search in its first life; don't let
9501
9632
  // the settle/finalize path re-count it as a new one.
9502
9633
  this.lastFinalizedQuery = snap.query.trim().toLowerCase();
@@ -9542,7 +9673,11 @@ class SearchWidget extends ElementWithStylesheet {
9542
9673
  ${this.renderMain()}
9543
9674
  `;
9544
9675
  }
9676
+ // The nudge leads the column — directly under the search bar, above the
9677
+ // typeahead chips and the grid — so it is seen before the results are
9678
+ // scanned rather than after they disappoint.
9545
9679
  return b `
9680
+ ${this.shouldShowAskNudge() ? this.renderAskNudge() : A}
9546
9681
  ${this.cachedRailSections.map((section) => this.renderColumnSection(section))}
9547
9682
  ${this.renderMain()}
9548
9683
  `;
@@ -9933,25 +10068,12 @@ class SearchWidget extends ElementWithStylesheet {
9933
10068
  height="14"
9934
10069
  viewBox="0 0 24 24"
9935
10070
  fill="none"
9936
- stroke=${isAi ? 'url(#aiGrad)' : 'currentColor'}
10071
+ stroke="currentColor"
9937
10072
  stroke-width="2.2"
9938
10073
  stroke-linecap="round"
9939
10074
  stroke-linejoin="round"
9940
10075
  aria-hidden="true"
9941
10076
  >
9942
- <defs>
9943
- <linearGradient
9944
- id="aiGrad"
9945
- x1="0"
9946
- y1="0"
9947
- x2="24"
9948
- y2="24"
9949
- gradientUnits="userSpaceOnUse"
9950
- >
9951
- <stop offset="0" stop-color="#9333EA"></stop>
9952
- <stop offset="1" stop-color="#6366F1"></stop>
9953
- </linearGradient>
9954
- </defs>
9955
10077
  <path
9956
10078
  d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"
9957
10079
  ></path>
@@ -10009,6 +10131,80 @@ class SearchWidget extends ElementWithStylesheet {
10009
10131
  </div>
10010
10132
  `;
10011
10133
  }
10134
+ // Whether the Ask AI offer belongs on screen right now. Lifted out of the
10135
+ // render so `renderColumn` can place the band above everything in the body
10136
+ // while still keeping the "real search results exist" conditions that only
10137
+ // renderMain used to know about.
10138
+ shouldShowAskNudge() {
10139
+ var _a, _b;
10140
+ if (!this.askActive || this.mode === 'ask') {
10141
+ return false;
10142
+ }
10143
+ const mode = (_b = (_a = this.uiPreferences) === null || _a === void 0 ? void 0 : _a.askNudge) !== null && _b !== void 0 ? _b : DEFAULT_ASK_NUDGE_MODE;
10144
+ if (mode === 'never' || (mode === 'weak' && this.hadLexicalMatch)) {
10145
+ return false;
10146
+ }
10147
+ // Never on the handpicked/empty state (no query to hand over), mid-load
10148
+ // (nothing to be unhappy with yet), on an error, or on the no-match state —
10149
+ // that one carries its own recovery CTA already. Collections count as
10150
+ // results: the same pair renderMain's `noMatches` tests, so a
10151
+ // collections-only match keeps the nudge rather than reading as empty.
10152
+ return (this.debouncedQuery.trim().length > 0 &&
10153
+ !this.loading &&
10154
+ !this.hasError &&
10155
+ (this.searchProducts.length > 0 || this.searchCollections.length > 0));
10156
+ }
10157
+ // Substitute the merchant's `{query}` token. Returned as separate strings so
10158
+ // Lit renders them as text bindings and escapes the shopper's query; never
10159
+ // build markup from this.
10160
+ askNudgeLabelParts(query) {
10161
+ var _a, _b;
10162
+ const label = ((_b = (_a = this.uiPreferences) === null || _a === void 0 ? void 0 : _a.askNudgeLabel) === null || _b === void 0 ? void 0 : _b.trim()) || DEFAULT_ASK_NUDGE_LABEL;
10163
+ return label.split(ASK_NUDGE_QUERY_TOKEN).join(`"${query}"`);
10164
+ }
10165
+ // Offer Ask AI above the results the shopper is about to scan. The tab pill
10166
+ // is too quiet to read as an offer, and the zero-result CTA never fires for
10167
+ // these queries by definition — this is the only place the offer lands while
10168
+ // the shopper still has the query in their head.
10169
+ renderAskNudge() {
10170
+ const query = this.debouncedQuery.trim();
10171
+ const label = this.askNudgeLabelParts(query);
10172
+ return b `
10173
+ <button
10174
+ type="button"
10175
+ class="ask-nudge"
10176
+ @click=${() => this.handleAskNudgeClick(query)}
10177
+ >
10178
+ <bt-lucide-icon
10179
+ name="sparkles"
10180
+ width="14"
10181
+ height="14"
10182
+ stroke="currentColor"
10183
+ stroke-width="1.5"
10184
+ ></bt-lucide-icon>
10185
+ <span class="ask-nudge-text">${label}</span>
10186
+ <bt-lucide-icon
10187
+ name="arrowRight"
10188
+ width="14"
10189
+ height="14"
10190
+ stroke="currentColor"
10191
+ stroke-width="1.5"
10192
+ ></bt-lucide-icon>
10193
+ </button>
10194
+ `;
10195
+ }
10196
+ // Same destination as the zero-result CTA: flip to Ask and run the query the
10197
+ // shopper already typed, so the words carry over verbatim.
10198
+ handleAskNudgeClick(query) {
10199
+ this.mode = 'ask';
10200
+ setStoredSearchMode('ask');
10201
+ // Reuse a held answer for this exact query, like the view toggle does —
10202
+ // re-submitting would open a second billed generation for the same words.
10203
+ const hasAnswer = this.askLoading || this.askAnswer.length > 0 || this.askEvidence !== null;
10204
+ if (!hasAnswer || this.lastAskedQuery !== query) {
10205
+ this.handleAskSubmit(query);
10206
+ }
10207
+ }
10012
10208
  renderMain() {
10013
10209
  var _a, _b, _c;
10014
10210
  const askInitial = this.mode === 'ask' &&
@@ -10224,6 +10420,10 @@ __decorate([
10224
10420
  r$1(),
10225
10421
  __metadata("design:type", Array)
10226
10422
  ], SearchWidget.prototype, "searchCollections", void 0);
10423
+ __decorate([
10424
+ r$1(),
10425
+ __metadata("design:type", Object)
10426
+ ], SearchWidget.prototype, "hadLexicalMatch", void 0);
10227
10427
  __decorate([
10228
10428
  r$1(),
10229
10429
  __metadata("design:type", Array)
@@ -10283,7 +10483,15 @@ const evoSearchStyles = i$6 `
10283
10483
  --evosearch-primary: #3a3d36;
10284
10484
  --evosearch-secondary: #6b6f64;
10285
10485
  --evosearch-accent: #e0590b;
10286
- --evosearch-ask-accent: #2563eb;
10486
+ /* Violet, not blue. The previous #2563eb read as a generic link colour, so
10487
+ every AI affordance looked like plain navigation. Violet is distinct from
10488
+ the brand accent AND from ordinary links, so "this is the AI" is legible
10489
+ without a second signal. Drives the mode toggle, the results nudge, the
10490
+ ask-answer rule, the follow-up chips and the state-block CTA. */
10491
+ --evosearch-ask-accent: #7c3aed;
10492
+ /* Paired end-stop for the gradient treatments (nudge label, toggle thumb).
10493
+ Same amethyst→indigo sweep the toggle icon used before it was unified. */
10494
+ --evosearch-ask-accent-alt: #6366f1;
10287
10495
  --evosearch-mode-accent: var(--evosearch-accent);
10288
10496
  --evosearch-white: #ffffff;
10289
10497
  --evosearch-border: #d6d8cf;
@@ -10359,6 +10567,25 @@ class EvoSearch extends ElementWithStylesheet {
10359
10567
  this.handpickedProducts = [];
10360
10568
  this.featuredCollections = [];
10361
10569
  this.askAiEnabled = false;
10570
+ // Host-page scroll locks (body-scroll-lock and friends) cancel every
10571
+ // document-level touchmove they didn't whitelist, which kills touch
10572
+ // scrolling inside the widget on iOS. Keep widget touches out of their
10573
+ // reach; never preventDefault, or we'd kill the scroll ourselves.
10574
+ // Known gap: a lock registered with { capture: true } runs before the
10575
+ // event reaches this host, so stopPropagation can't shield against it.
10576
+ this.touchmoveListener = (event) => event.stopPropagation();
10577
+ }
10578
+ connectedCallback() {
10579
+ super.connectedCallback();
10580
+ // Passive: this handler only stops propagation, and a blocking touchmove
10581
+ // listener would keep the widget's own scrolling off the compositor.
10582
+ this.addEventListener('touchmove', this.touchmoveListener, {
10583
+ passive: true,
10584
+ });
10585
+ }
10586
+ disconnectedCallback() {
10587
+ this.removeEventListener('touchmove', this.touchmoveListener);
10588
+ super.disconnectedCallback();
10362
10589
  }
10363
10590
  updated(changedProperties) {
10364
10591
  var _a;
@@ -10558,7 +10785,7 @@ const tryFetchTrending = (target) => {
10558
10785
  // Runtime Ask AI config. Unlike trending (fetched once per session), this is
10559
10786
  // fetched on every open so a merchant's dashboard change takes effect without
10560
10787
  // a reload — there is no session-fetched latch, only an in-flight guard to
10561
- // dedupe concurrent opens. `askStarters`/`askAiEnabled` apply live.
10788
+ // dedupe concurrent opens. `askStarters`/`askAiEnabled`/`askNudge`(+label) apply live.
10562
10789
  const tryFetchConfig = (target) => {
10563
10790
  if (!target.api || configController) {
10564
10791
  return;
@@ -10576,6 +10803,13 @@ const tryFetchConfig = (target) => {
10576
10803
  ...(data.askStarters !== undefined
10577
10804
  ? { askStarters: data.askStarters }
10578
10805
  : {}),
10806
+ // Only overlay what the worker actually sent: during a deploy window
10807
+ // a new SDK can hit an old `/config` that omits these, and writing
10808
+ // undefined would drop the manifest-seeded value.
10809
+ ...(data.askNudge !== undefined ? { askNudge: data.askNudge } : {}),
10810
+ ...(data.askNudgeLabel !== undefined
10811
+ ? { askNudgeLabel: data.askNudgeLabel }
10812
+ : {}),
10579
10813
  };
10580
10814
  }
10581
10815
  })