@syntrologie/adapt-search 2.8.0-canary.568

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,1389 @@
1
+ import {
2
+ external_exports,
3
+ skeletonRegions
4
+ } from "./chunk-NKOTQPVZ.js";
5
+
6
+ // ../../../node_modules/@lit/context/lib/create-context.js
7
+ function n(n2) {
8
+ return n2;
9
+ }
10
+
11
+ // ../../sdk-contracts/dist/canvas-context.js
12
+ var canvasRuntimeContext = n("syntrologie:canvas-runtime");
13
+
14
+ // ../../sdk-contracts/dist/detector-events.js
15
+ var DETECTOR_EVENT_NAMES = [
16
+ "ui.hover",
17
+ "ui.idle",
18
+ "ui.hesitate",
19
+ "ui.rage_click",
20
+ "ui.scroll_thrash",
21
+ "ui.focus_bounce"
22
+ ];
23
+ var CANONICAL_BUS_EVENT_NAMES = [
24
+ ...DETECTOR_EVENT_NAMES,
25
+ "nav.section_viewed",
26
+ "nav.scroll_depth"
27
+ ];
28
+
29
+ // ../../sdk-contracts/dist/mount-plumbing.js
30
+ var MOUNT_PLUMBING_KEYS = ["instanceId", "runtime", "tileId"];
31
+ function stripMountPlumbing(config) {
32
+ if (!config || typeof config !== "object") {
33
+ return {};
34
+ }
35
+ const out = { ...config };
36
+ for (const key of MOUNT_PLUMBING_KEYS) {
37
+ delete out[key];
38
+ }
39
+ return out;
40
+ }
41
+
42
+ // ../../sdk-contracts/dist/routes.js
43
+ var RESERVED_BYTES = /* @__PURE__ */ new Set([
44
+ 33,
45
+ // !
46
+ 35,
47
+ // #
48
+ 36,
49
+ // $
50
+ 38,
51
+ // &
52
+ 39,
53
+ // '
54
+ 40,
55
+ // (
56
+ 41,
57
+ // )
58
+ 42,
59
+ // *
60
+ 43,
61
+ // +
62
+ 44,
63
+ // ,
64
+ 47,
65
+ // /
66
+ 58,
67
+ // :
68
+ 59,
69
+ // ;
70
+ 61,
71
+ // =
72
+ 63,
73
+ // ?
74
+ 64,
75
+ // @
76
+ 91,
77
+ // [
78
+ 93
79
+ // ]
80
+ ]);
81
+ var utf8Decoder = new TextDecoder("utf-8", { fatal: false });
82
+ function decodeUnreservedOnly(input) {
83
+ let out = "";
84
+ let pending = [];
85
+ const flushPending = () => {
86
+ if (pending.length === 0)
87
+ return;
88
+ const bytes = new Uint8Array(pending);
89
+ out += utf8Decoder.decode(bytes);
90
+ pending = [];
91
+ };
92
+ let i2 = 0;
93
+ while (i2 < input.length) {
94
+ const ch = input[i2];
95
+ if (ch === "%" && i2 + 2 < input.length && isHex(input[i2 + 1]) && isHex(input[i2 + 2])) {
96
+ const byte = parseInt(input.slice(i2 + 1, i2 + 3), 16);
97
+ if (RESERVED_BYTES.has(byte)) {
98
+ flushPending();
99
+ out += `%${input.slice(i2 + 1, i2 + 3).toUpperCase()}`;
100
+ i2 += 3;
101
+ } else {
102
+ pending.push(byte);
103
+ i2 += 3;
104
+ }
105
+ } else {
106
+ flushPending();
107
+ out += ch;
108
+ i2 += 1;
109
+ }
110
+ }
111
+ flushPending();
112
+ return out;
113
+ }
114
+ function isHex(c2) {
115
+ return c2 >= "0" && c2 <= "9" || c2 >= "a" && c2 <= "f" || c2 >= "A" && c2 <= "F";
116
+ }
117
+ function stripQueryAndHash(s4) {
118
+ const q = s4.indexOf("?");
119
+ if (q !== -1)
120
+ s4 = s4.slice(0, q);
121
+ const h = s4.indexOf("#");
122
+ if (h !== -1)
123
+ s4 = s4.slice(0, h);
124
+ return s4;
125
+ }
126
+ function normalizeRoute(path) {
127
+ if (typeof path !== "string" || path.length === 0) {
128
+ throw new TypeError("normalizeRoute: input must be a non-empty string");
129
+ }
130
+ if (!path.startsWith("/")) {
131
+ throw new TypeError(`normalizeRoute: input must be absolute (start with '/'); got ${JSON.stringify(path)}`);
132
+ }
133
+ let s4 = stripQueryAndHash(path);
134
+ s4 = decodeUnreservedOnly(s4);
135
+ s4 = s4.replace(/\/+/g, "/");
136
+ if (s4.length > 1 && s4.endsWith("/"))
137
+ s4 = s4.slice(0, -1);
138
+ return s4;
139
+ }
140
+
141
+ // ../../sdk-contracts/dist/schemas.js
142
+ var NO_CSS_BREAKOUT_PATTERN = /^[^{}]*$/;
143
+ var AnchorIdZ = external_exports.object({
144
+ selector: external_exports.string().regex(NO_CSS_BREAKOUT_PATTERN, {
145
+ message: 'selector must not contain "{" or "}" \u2014 not valid CSS selector syntax, and content:hideBySelector injects this value directly into a <style> element where these characters would break out of the generated rule.'
146
+ }).describe("CSS selector for the target element"),
147
+ route: external_exports.union([external_exports.string(), external_exports.array(external_exports.string())]).superRefine((value, ctx) => {
148
+ for (const route of Array.isArray(value) ? value : [value]) {
149
+ let canonical;
150
+ try {
151
+ canonical = normalizeRoute(route);
152
+ } catch (err) {
153
+ ctx.addIssue({
154
+ code: external_exports.ZodIssueCode.custom,
155
+ message: `route must be an absolute path starting with "/" (got ${JSON.stringify(route)}): ${err instanceof Error ? err.message : String(err)}`
156
+ });
157
+ continue;
158
+ }
159
+ if (canonical !== route) {
160
+ ctx.addIssue({
161
+ code: external_exports.ZodIssueCode.custom,
162
+ message: `route ${JSON.stringify(route)} is not canonical \u2014 use ${JSON.stringify(canonical)} (this must match what the backend's RouteCanonicalityCheck accepts)`
163
+ });
164
+ }
165
+ }
166
+ }).describe("URL path(s) where this element exists")
167
+ }).strict().describe("DOM element target. selector = CSS selector, route = URL path(s) where the element exists.");
168
+ var AuthoringFieldsZ = {
169
+ id: external_exports.string().optional().describe('Stable action identifier (e.g. "act_3db6a14d2ab0").'),
170
+ title: external_exports.string().max(200).optional().describe("Authoring-only: short label shown on the action plan dashboard. Stripped before serving to the runtime SDK."),
171
+ description: external_exports.string().max(1e3).optional().describe("Authoring-only: one-sentence explanation of what this action does and why. Stripped before serving to the runtime SDK."),
172
+ validation: external_exports.array(external_exports.string().max(500)).max(10).optional().describe("Authoring-only: ordered steps a reviewer can follow to trigger this action and visually confirm it works. Each entry is one step. Stripped before serving to the runtime SDK.")
173
+ };
174
+ var COUNTABLE_EVENTS = [
175
+ // User interactions (from PostHog autocapture normalization)
176
+ "ui.click",
177
+ "ui.scroll",
178
+ "ui.input",
179
+ "ui.change",
180
+ "ui.submit",
181
+ // Behavioral detectors (from event-processor)
182
+ "ui.hover",
183
+ "ui.idle",
184
+ "ui.scroll_thrash",
185
+ "ui.focus_bounce",
186
+ "ui.hesitate",
187
+ "ui.rage_click",
188
+ // Navigation
189
+ "nav.page_view",
190
+ "nav.page_leave"
191
+ ];
192
+ var CountableEventZ = external_exports.enum(COUNTABLE_EVENTS).describe("Event name to count. ui.* = user interactions and behavioral detectors (hesitate, rage_click, scroll_thrash, focus_bounce, idle, hover); nav.* = page navigation.");
193
+ var SESSION_METRIC_KEYS = ["time_on_page", "page_views", "scroll_depth"];
194
+ var SessionMetricKeyZ = external_exports.enum(SESSION_METRIC_KEYS).describe("Session metric key. time_on_page = seconds on current page, page_views = pages visited this session, scroll_depth = 0-100 percentage.");
195
+ var PageUrlConditionZ = external_exports.object({
196
+ type: external_exports.literal("page_url"),
197
+ url: external_exports.string().describe('URL path to match (e.g. "/pricing", "/dashboard")')
198
+ }).describe('Fires when the current page URL matches. Use for page-specific actions. Example: {"type": "page_url", "url": "/pricing"}');
199
+ var RouteConditionZ = external_exports.object({
200
+ type: external_exports.literal("route"),
201
+ routeId: external_exports.string().describe("Named route ID from the route filter")
202
+ }).describe("Fires when the current route matches a named route ID.");
203
+ var AnchorVisibleConditionZ = external_exports.object({
204
+ type: external_exports.literal("anchor_visible"),
205
+ anchorId: external_exports.string().describe("CSS selector of the anchor element"),
206
+ state: external_exports.enum(["visible", "present", "absent"]).describe('"visible" = in viewport, "present" = in DOM, "absent" = not in DOM')
207
+ }).describe(`Fires based on a DOM element's visibility state. Example: {"type": "anchor_visible", "anchorId": "#cta-button", "state": "visible"}`);
208
+ var EventOccurredConditionZ = external_exports.object({
209
+ type: external_exports.literal("event_occurred"),
210
+ eventName: external_exports.string().describe('Event name (e.g. "ui.click", "$pageview")'),
211
+ withinMs: external_exports.number().optional().describe("Time window in ms. Omit = any time this session.")
212
+ }).describe('Fires when a specific event has occurred during this session. Example: {"type": "event_occurred", "eventName": "ui.click", "withinMs": 5000}');
213
+ var StateEqualsConditionZ = external_exports.object({
214
+ type: external_exports.literal("state_equals"),
215
+ key: external_exports.string().describe("Key in the SDK persistent state store (localStorage). Only valid for keys the host app explicitly sets via syntro.state.set()."),
216
+ value: external_exports.unknown().describe("Expected value to match against")
217
+ }).describe("Checks the SDK persistent state store (localStorage). ONLY for host-app state set via syntro.state.set() \u2014 NOT for user attributes like region, device, or UTM params (those are handled by segment targeting). Do NOT use this for targeting. If you do not know the valid state keys, do not use this condition type.");
218
+ var ViewportConditionZ = external_exports.object({
219
+ type: external_exports.literal("viewport"),
220
+ minWidth: external_exports.number().optional().describe("Minimum viewport width in pixels"),
221
+ maxWidth: external_exports.number().optional().describe("Maximum viewport width in pixels"),
222
+ minHeight: external_exports.number().optional().describe("Minimum viewport height in pixels"),
223
+ maxHeight: external_exports.number().optional().describe("Maximum viewport height in pixels")
224
+ }).describe('Fires based on viewport (screen) size. Use for responsive behavior. Example: {"type": "viewport", "minWidth": 768} \u2014 fires on tablet and larger.');
225
+ var SessionMetricConditionZ = external_exports.object({
226
+ type: external_exports.literal("session_metric"),
227
+ key: SessionMetricKeyZ,
228
+ operator: external_exports.enum(["gte", "lte", "eq", "gt", "lt"]),
229
+ threshold: external_exports.number().describe("Numeric threshold to compare against")
230
+ }).describe('Fires when a session metric crosses a threshold. Valid keys: "time_on_page" (seconds), "page_views" (count), "scroll_depth" (0-100). Example: {"type": "session_metric", "key": "time_on_page", "operator": "gte", "threshold": 30}');
231
+ var DismissedConditionZ = external_exports.object({
232
+ type: external_exports.literal("dismissed"),
233
+ key: external_exports.string().describe("Dismissal key (usually a tile or action ID)"),
234
+ inverted: external_exports.boolean().optional().describe("When true, fires if NOT dismissed (default behavior)")
235
+ }).describe("Checks if an item has been dismissed by the user. Use with inverted: true to show only if not dismissed.");
236
+ var CooldownActiveConditionZ = external_exports.object({
237
+ type: external_exports.literal("cooldown_active"),
238
+ key: external_exports.string().describe("Cooldown key"),
239
+ inverted: external_exports.boolean().optional().describe("When true, fires if cooldown is NOT active")
240
+ }).describe("Checks if a cooldown timer is currently active. Use to prevent showing the same intervention too frequently.");
241
+ var FrequencyLimitConditionZ = external_exports.object({
242
+ type: external_exports.literal("frequency_limit"),
243
+ key: external_exports.string().describe("Frequency counter key"),
244
+ limit: external_exports.number().describe("Maximum allowed count"),
245
+ inverted: external_exports.boolean().optional().describe("When true, fires if limit NOT reached")
246
+ }).describe("Checks if a frequency limit has been reached. Use to cap how many times an action fires per session.");
247
+ var MatchOpZ = external_exports.object({
248
+ equals: external_exports.union([external_exports.string(), external_exports.number(), external_exports.boolean()]).optional(),
249
+ contains: external_exports.string().optional()
250
+ }).refine((operator) => Number(operator.equals !== void 0) + Number(operator.contains !== void 0) === 1, {
251
+ message: "Exactly one of equals or contains must be specified."
252
+ }).describe("Match operator for counter filters. Exactly one of equals or contains must be specified.");
253
+ var CounterDefZ = external_exports.object({
254
+ events: external_exports.array(CountableEventZ).min(1).describe("Event names to count. Use values from the countable events enum."),
255
+ match: external_exports.record(external_exports.string(), MatchOpZ).optional().describe("Property filters. Keys are event prop names or element-chain fields (tag_name, $el_text, attr__*). All entries AND together.")
256
+ }).describe("Defines what events to count. Registered as an accumulator predicate at config-load time.");
257
+ var EventCountConditionZ = external_exports.object({
258
+ type: external_exports.literal("event_count"),
259
+ key: external_exports.string().describe("Unique key for this counter (used for accumulator registration)"),
260
+ operator: external_exports.enum(["gte", "lte", "eq", "gt", "lt"]),
261
+ count: external_exports.number().int().min(0).describe("Target count threshold"),
262
+ withinMs: external_exports.number().positive().optional().describe("Time window in ms. Omit = count across entire session."),
263
+ counter: CounterDefZ.optional().describe("Inline counter definition. Defines what events to count.")
264
+ }).describe('Fires when accumulated event count crosses a threshold. Most powerful trigger type. Example: {"type": "event_count", "key": "pricing-clicks", "operator": "gte", "count": 3, "counter": {"events": ["ui.click"], "match": {"attr__data-cta": {"contains": "pricing"}}}}');
265
+ var ConditionZ = external_exports.discriminatedUnion("type", [
266
+ PageUrlConditionZ,
267
+ RouteConditionZ,
268
+ AnchorVisibleConditionZ,
269
+ EventOccurredConditionZ,
270
+ StateEqualsConditionZ,
271
+ ViewportConditionZ,
272
+ SessionMetricConditionZ,
273
+ DismissedConditionZ,
274
+ CooldownActiveConditionZ,
275
+ FrequencyLimitConditionZ,
276
+ EventCountConditionZ
277
+ ]);
278
+ var RuleZ = external_exports.object({
279
+ conditions: external_exports.array(ConditionZ).describe("Array of conditions \u2014 ALL must match (AND logic) for this rule to fire."),
280
+ value: external_exports.unknown().describe("Value returned when all conditions match. For triggerWhen: true = fire the action.")
281
+ }).describe("A single rule. ALL conditions must match (AND logic). Rules in a strategy are evaluated top-to-bottom \u2014 first rule where all conditions match wins and returns its value.");
282
+ var RuleStrategyZ = external_exports.object({
283
+ type: external_exports.literal("rules"),
284
+ rules: external_exports.array(RuleZ).describe("Ordered list of rules. Evaluated top-to-bottom \u2014 first match wins."),
285
+ default: external_exports.unknown().describe("Fallback value when no rule matches. For triggerWhen: false = do not fire by default.")
286
+ }).describe("Rule-based strategy. Evaluates rules top-to-bottom. First rule where ALL conditions match returns its value. If no rule matches, returns default. For triggerWhen: set value=true on matching rules, default=false.");
287
+ var ScoreStrategyZ = external_exports.object({
288
+ type: external_exports.literal("score"),
289
+ field: external_exports.string(),
290
+ threshold: external_exports.number(),
291
+ above: external_exports.unknown(),
292
+ below: external_exports.unknown()
293
+ }).describe("Score-based strategy. Compares a field value against a threshold.");
294
+ var ModelStrategyZ = external_exports.object({
295
+ type: external_exports.literal("model"),
296
+ modelId: external_exports.string(),
297
+ inputs: external_exports.array(external_exports.string()),
298
+ outputMapping: external_exports.record(external_exports.string(), external_exports.unknown()),
299
+ default: external_exports.unknown()
300
+ }).describe("ML model strategy. Sends inputs to a model and maps outputs.");
301
+ var ExternalStrategyZ = external_exports.object({
302
+ type: external_exports.literal("external"),
303
+ endpoint: external_exports.string(),
304
+ method: external_exports.enum(["GET", "POST"]).optional(),
305
+ default: external_exports.unknown(),
306
+ timeoutMs: external_exports.number().optional()
307
+ }).describe("External API strategy. Calls an endpoint to determine the value.");
308
+ var DecisionStrategyZ = external_exports.discriminatedUnion("type", [
309
+ RuleStrategyZ,
310
+ ScoreStrategyZ,
311
+ ModelStrategyZ,
312
+ ExternalStrategyZ
313
+ ]);
314
+ var TriggerWhenZ = DecisionStrategyZ.nullable().optional();
315
+ var EventScopeZ = external_exports.object({
316
+ events: external_exports.array(external_exports.string()),
317
+ urlContains: external_exports.string().optional(),
318
+ props: external_exports.record(external_exports.union([external_exports.string(), external_exports.number(), external_exports.boolean()])).optional()
319
+ });
320
+ var NotifyZ = external_exports.object({
321
+ title: external_exports.string().optional().describe("Notification title"),
322
+ body: external_exports.string().optional().describe("Notification body text"),
323
+ icon: external_exports.string().optional().describe("Notification icon (emoji or URL)")
324
+ }).describe("Optional toast notification shown when this action triggers.").nullable().optional();
325
+
326
+ // ../../sdk-contracts/dist/telemetry-events.js
327
+ var entry = (e2) => e2;
328
+ var TELEMETRY_EVENTS = {
329
+ // --- intervention metrics (April 2026) — wire names unchanged, all plan-scope
330
+ syntro_config_served: entry({
331
+ subject: "config",
332
+ verb: "served",
333
+ scope: "plan",
334
+ props: ["tiles", "actions"]
335
+ }),
336
+ syntro_intervention_seen: entry({
337
+ subject: "intervention",
338
+ verb: "seen",
339
+ scope: "plan",
340
+ props: ["intervention_id", "intervention_kind"]
341
+ }),
342
+ syntro_intervention_triggered: entry({
343
+ subject: "intervention",
344
+ verb: "triggered",
345
+ scope: "plan",
346
+ props: ["intervention_id", "intervention_kind"]
347
+ }),
348
+ syntro_intervention_interacted: entry({
349
+ subject: "intervention",
350
+ verb: "interacted",
351
+ scope: "plan",
352
+ props: ["intervention_id", "intervention_kind", "interaction_type"]
353
+ }),
354
+ // --- concierge (FEAT-1789150184) ---------------------------------------
355
+ syntro_concierge_opened: entry({
356
+ subject: "concierge",
357
+ verb: "opened",
358
+ scope: "open",
359
+ props: ["trigger", "page_path"]
360
+ // trigger: ConciergeOpenTrigger
361
+ }),
362
+ syntro_concierge_closed: entry({
363
+ subject: "concierge",
364
+ verb: "closed",
365
+ scope: "open",
366
+ props: ["duration_ms", "reason", "messages_sent", "tiles_dismissed", "pages_viewed"]
367
+ // reason: ConciergeCloseReason
368
+ }),
369
+ syntro_deck_paged: entry({
370
+ subject: "deck",
371
+ verb: "paged",
372
+ scope: "open",
373
+ props: ["from_index", "to_index", "method"]
374
+ // method: DeckPageMethod
375
+ }),
376
+ syntro_tile_dismissed: entry({
377
+ subject: "tile",
378
+ verb: "dismissed",
379
+ scope: "open",
380
+ props: ["tile_id", "tile_kind", "visible_ms"]
381
+ }),
382
+ // The three chat atoms are OPEN-scope: they carry `surface` / `open_id` and
383
+ // only make sense inside a concierge open. An INLINE chat bar (no concierge)
384
+ // still dispatches them on every turn, and the core drops each one and
385
+ // increments the `telemetry_dropped` health counter. That is EXPECTED on an
386
+ // inline-chat host page, not a bug — `telemetry_dropped` there mixes "real
387
+ // defect" with "inline chat has no open scope", so do not alert on it alone.
388
+ syntro_chat_message_sent: entry({
389
+ subject: "chat_message",
390
+ verb: "sent",
391
+ scope: "open",
392
+ props: ["turn", "chars", "via"]
393
+ // via: ChatSendVia
394
+ }),
395
+ syntro_chat_reply_received: entry({
396
+ subject: "chat_reply",
397
+ verb: "received",
398
+ scope: "open",
399
+ props: ["turn", "ttft_ms", "total_ms", "mounts", "cards"]
400
+ }),
401
+ syntro_chat_interrupted: entry({
402
+ subject: "chat",
403
+ verb: "interrupted",
404
+ scope: "open",
405
+ props: ["turn", "after_ms"]
406
+ }),
407
+ syntro_chat_maximize_toggled: entry({
408
+ subject: "chat_maximize",
409
+ verb: "toggled",
410
+ scope: "open",
411
+ props: ["maximized"]
412
+ }),
413
+ syntro_chat_takeover_triggered: entry({
414
+ subject: "chat_takeover",
415
+ verb: "triggered",
416
+ scope: "open",
417
+ props: ["turn"]
418
+ }),
419
+ // --- search takeover (2026-09-17) ---------------------------------------
420
+ // All plan-scope: the search interceptor fires before any concierge open
421
+ // exists. No prop here ever carries query text (SEE the contract test's
422
+ // no-text-prop rule) — classification/signal_hits are the interceptor's
423
+ // own derived signals, never the string itself.
424
+ syntro_search_sent: entry({
425
+ subject: "search",
426
+ verb: "sent",
427
+ scope: "plan",
428
+ props: ["classification", "word_count", "signal_hits", "entry_point", "platform"]
429
+ // classification: SearchClassification, entry_point: SearchEntryPoint
430
+ }),
431
+ // `ms_to_skeleton` is intentionally the only timing prop: it is always
432
+ // knowable the instant the surface mounts. `ms_to_first_content` is NOT
433
+ // declared here (revision 1 defect) because a surface that never painted
434
+ // content still fires this event, and a required prop it cannot supply
435
+ // would make TelemetryCore.emit drop exactly the row worth measuring.
436
+ syntro_search_surface_seen: entry({
437
+ subject: "search_surface",
438
+ verb: "seen",
439
+ scope: "plan",
440
+ props: ["variant", "classification", "result_count", "ms_to_skeleton"]
441
+ // variant: SearchVariant, classification: SearchClassification
442
+ }),
443
+ syntro_search_result_tapped: entry({
444
+ subject: "search_result",
445
+ verb: "tapped",
446
+ scope: "plan",
447
+ props: ["variant", "region_id", "module", "position"]
448
+ // variant: SearchVariant
449
+ }),
450
+ syntro_search_followup_sent: entry({
451
+ subject: "search_followup",
452
+ verb: "sent",
453
+ scope: "plan",
454
+ props: ["delta_kind", "variant"]
455
+ // delta_kind: SearchDeltaKind, variant: SearchVariant
456
+ }),
457
+ // Declares `ms_since_boot` (not a "since surface mounted" timing prop) so a
458
+ // not-ready fallback the takeover never rendered is still measurable —
459
+ // revision 1 emitted nothing on this path.
460
+ syntro_search_native_served: entry({
461
+ subject: "search_native",
462
+ verb: "served",
463
+ scope: "plan",
464
+ props: ["reason", "platform", "ms_since_boot"]
465
+ // reason: SearchFallbackReason
466
+ }),
467
+ syntro_search_takeover_dismissed: entry({
468
+ subject: "search_takeover",
469
+ verb: "dismissed",
470
+ scope: "plan",
471
+ props: ["variant", "reason"]
472
+ // variant: SearchVariant, reason: SearchDismissReason
473
+ })
474
+ };
475
+ var TELEMETRY_EVENT_NAMES = Object.keys(TELEMETRY_EVENTS);
476
+
477
+ // src/SearchSurfaceElement.ts
478
+ import { css, html as html2, LitElement, nothing as nothing2 } from "lit";
479
+ import { repeat } from "lit/directives/repeat.js";
480
+
481
+ // src/regions.ts
482
+ import { html, nothing } from "lit";
483
+
484
+ // src/hrefSafety.ts
485
+ var ALLOWED_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:", "mailto:", "tel:"]);
486
+ var UNSAFE_HREF_FALLBACK = "#";
487
+ function isSafeProductHref(href) {
488
+ if (typeof href !== "string" || href.trim().length === 0) return false;
489
+ let parsed;
490
+ try {
491
+ parsed = new URL(href, "https://syntro.local/");
492
+ } catch {
493
+ return false;
494
+ }
495
+ return ALLOWED_PROTOCOLS.has(parsed.protocol);
496
+ }
497
+ function safeProductHref(href) {
498
+ return isSafeProductHref(href) ? href : UNSAFE_HREF_FALLBACK;
499
+ }
500
+
501
+ // src/regions.ts
502
+ var RESULT_TAP_EVENT = "syntro:search:result-tap";
503
+ var FOLLOWUP_EVENT = "syntro:search:followup";
504
+ var SEE_STORE_RESULTS_EVENT = "syntro:search:see-store-results";
505
+ function dispatchFrom(node, name, detail) {
506
+ node.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: false }));
507
+ }
508
+ function asRecord(value) {
509
+ return value !== null && typeof value === "object" ? value : {};
510
+ }
511
+ function asString(value) {
512
+ return typeof value === "string" && value.length > 0 ? value : void 0;
513
+ }
514
+ function readProduct(value) {
515
+ const raw = asRecord(value);
516
+ const handle = asString(raw.handle);
517
+ const title = asString(raw.title);
518
+ const url = asString(raw.url);
519
+ if (!handle || !title || !url) return null;
520
+ return { handle, title, url, price: asString(raw.price), imageUrl: asString(raw.imageUrl) };
521
+ }
522
+ function readProducts(value) {
523
+ if (!Array.isArray(value)) return [];
524
+ return value.map(readProduct).filter((product) => product !== null);
525
+ }
526
+ function readStrings(value) {
527
+ if (!Array.isArray(value)) return [];
528
+ return value.filter((entry2) => typeof entry2 === "string" && entry2.length > 0);
529
+ }
530
+ function readChips(value) {
531
+ if (!Array.isArray(value)) return [];
532
+ return value.map((entry2) => {
533
+ const raw = asRecord(entry2);
534
+ const key = asString(raw.key);
535
+ const label = asString(raw.label) ?? key;
536
+ return key && label ? { key, label } : null;
537
+ }).filter((chip) => chip !== null);
538
+ }
539
+ function readSuggestions(value) {
540
+ if (!Array.isArray(value)) return [];
541
+ return value.map((entry2) => {
542
+ const raw = asRecord(entry2);
543
+ const suggestion = asString(raw.value);
544
+ const label = asString(raw.label) ?? suggestion;
545
+ return suggestion && label ? { value: suggestion, label } : null;
546
+ }).filter((entry2) => entry2 !== null);
547
+ }
548
+ function readFollowupBarPayload(payload) {
549
+ const raw = asRecord(payload);
550
+ return {
551
+ query: asString(raw.query) ?? "",
552
+ chips: readChips(raw.chips),
553
+ suggestions: readSuggestions(raw.suggestions),
554
+ notice: asString(raw.notice) ?? ""
555
+ };
556
+ }
557
+ var NONE_DISMISSED = /* @__PURE__ */ new Set();
558
+ function visible(products, dismissed) {
559
+ return dismissed.size === 0 ? products : products.filter((product) => !dismissed.has(product.handle));
560
+ }
561
+ function visibleOne(product, dismissed) {
562
+ return product !== null && dismissed.has(product.handle) ? null : product;
563
+ }
564
+ function countRegionProducts(region, dismissed = NONE_DISMISSED) {
565
+ const raw = asRecord(region.payload);
566
+ switch (region.module) {
567
+ case "search:product-grid":
568
+ case "search:shortlist":
569
+ case "search:strip":
570
+ return visible(readProducts(raw.products), dismissed).length;
571
+ case "search:group":
572
+ return Array.isArray(raw.groups) ? raw.groups.reduce(
573
+ (total, group) => total + visible(readProducts(asRecord(group).products), dismissed).length,
574
+ 0
575
+ ) : 0;
576
+ case "search:hero":
577
+ case "search:compare":
578
+ return visibleOne(readProduct(raw.product), dismissed) === null ? 0 : 1;
579
+ default:
580
+ return 0;
581
+ }
582
+ }
583
+ function regionHasAnswer(region, dismissed = NONE_DISMISSED) {
584
+ if (countRegionProducts(region, dismissed) > 0) return true;
585
+ if (region.module !== "search:content") return false;
586
+ const raw = asRecord(region.payload);
587
+ return readStrings(raw.blocks).length > 0 || asString(raw.body) !== void 0;
588
+ }
589
+ function productTile(product, region, position) {
590
+ const href = safeProductHref(product.url);
591
+ const imageHref = product.imageUrl ? safeProductHref(product.imageUrl) : void 0;
592
+ const detail = {
593
+ handle: product.handle,
594
+ regionId: region.id,
595
+ module: region.module,
596
+ position
597
+ };
598
+ return html`
599
+ <div class="tile">
600
+ <a
601
+ class="tile-link"
602
+ href=${href}
603
+ data-handle=${product.handle}
604
+ data-region-id=${region.id}
605
+ data-position=${position}
606
+ data-unsafe-url=${href === UNSAFE_HREF_FALLBACK ? "true" : nothing}
607
+ @click=${(event) => {
608
+ dispatchFrom(event.currentTarget, RESULT_TAP_EVENT, detail);
609
+ }}
610
+ >
611
+ ${imageHref && imageHref !== UNSAFE_HREF_FALLBACK ? html`<img class="tile-image" src=${imageHref} alt="" loading="lazy" />` : nothing}
612
+ <span class="tile-title">${product.title}</span>
613
+ ${product.price ? html`<span class="tile-price">${product.price}</span>` : nothing}
614
+ </a>
615
+ <button
616
+ class="tile-dismiss"
617
+ type="button"
618
+ data-dismiss=${product.handle}
619
+ aria-label=${`Hide ${product.title}`}
620
+ @click=${(event) => {
621
+ const detail2 = { kind: "item_dismissed", value: product.handle };
622
+ dispatchFrom(event.currentTarget, FOLLOWUP_EVENT, detail2);
623
+ }}
624
+ >
625
+ <span aria-hidden="true">✕</span>
626
+ </button>
627
+ </div>
628
+ `;
629
+ }
630
+ function productList(products, region, className, positionOffset = 0) {
631
+ return html`
632
+ <div class=${className}>
633
+ ${products.map((product, index) => productTile(product, region, positionOffset + index))}
634
+ </div>
635
+ `;
636
+ }
637
+ function followupBar(region) {
638
+ const { query, chips, suggestions, notice } = readFollowupBarPayload(region.payload);
639
+ return html`
640
+ <div class="followup">
641
+ <input
642
+ class="followup-input"
643
+ type="search"
644
+ aria-label="Refine your search"
645
+ .value=${query}
646
+ @keydown=${(event) => {
647
+ if (event.key !== "Enter") return;
648
+ const input = event.currentTarget;
649
+ const detail = { kind: "refine", value: input.value };
650
+ dispatchFrom(input, FOLLOWUP_EVENT, detail);
651
+ }}
652
+ />
653
+ <div class="chips" role="group" aria-label="What we understood">
654
+ ${chips.map(
655
+ (chip) => html`
656
+ <button
657
+ class="chip"
658
+ type="button"
659
+ data-chip=${chip.key}
660
+ aria-label=${`Remove ${chip.label}`}
661
+ @click=${(event) => {
662
+ const detail = { kind: "chip_removed", value: chip.key };
663
+ dispatchFrom(event.currentTarget, FOLLOWUP_EVENT, detail);
664
+ }}
665
+ >
666
+ <span>${chip.label}</span><span aria-hidden="true">✕</span>
667
+ </button>
668
+ `
669
+ )}
670
+ </div>
671
+ ${// Server prose, under the chips it explains: "Nothing under $5. Here
672
+ // are the closest matches." No node at all when the plan sent none,
673
+ // so an empty line never pushes the results down.
674
+ notice ? html`<p class="followup-notice">${notice}</p>` : nothing}
675
+ <div class="suggestions" role="group" aria-label="Suggested follow-ups">
676
+ ${suggestions.map(
677
+ (suggestion) => html`
678
+ <button
679
+ class="suggestion"
680
+ type="button"
681
+ data-suggestion=${suggestion.value}
682
+ @click=${(event) => {
683
+ const detail = { kind: "refine", value: suggestion.value };
684
+ dispatchFrom(event.currentTarget, FOLLOWUP_EVENT, detail);
685
+ }}
686
+ >
687
+ ${suggestion.label}
688
+ </button>
689
+ `
690
+ )}
691
+ </div>
692
+ </div>
693
+ `;
694
+ }
695
+ function groupedPicks(region, dismissed) {
696
+ const raw = asRecord(region.payload);
697
+ const groups = Array.isArray(raw.groups) ? raw.groups : [];
698
+ let position = 0;
699
+ return html`
700
+ ${groups.map((group) => {
701
+ const entry2 = asRecord(group);
702
+ const title = asString(entry2.title);
703
+ const products = visible(readProducts(entry2.products), dismissed);
704
+ const offset = position;
705
+ position += products.length;
706
+ return html`
707
+ <section class="group">
708
+ ${title ? html`<h3 class="group-title">${title}</h3>` : nothing}
709
+ ${productList(products, region, "tiles", offset)}
710
+ </section>
711
+ `;
712
+ })}
713
+ `;
714
+ }
715
+ function heroProduct(region, dismissed) {
716
+ const raw = asRecord(region.payload);
717
+ const product = visibleOne(readProduct(raw.product), dismissed);
718
+ if (!product) return nothing;
719
+ const reason = asString(raw.reason);
720
+ return html`
721
+ <div class="hero">
722
+ ${productTile(product, region, 0)}
723
+ ${reason ? html`<p class="hero-reason">${reason}</p>` : nothing}
724
+ </div>
725
+ `;
726
+ }
727
+ function compareColumn(region, dismissed) {
728
+ const raw = asRecord(region.payload);
729
+ const product = visibleOne(readProduct(raw.product), dismissed);
730
+ if (!product) return nothing;
731
+ const attributes = readStrings(raw.attributes);
732
+ return html`
733
+ <div class="compare">
734
+ ${productTile(product, region, 0)}
735
+ <ul class="compare-attributes">
736
+ ${attributes.map((attribute) => html`<li>${attribute}</li>`)}
737
+ </ul>
738
+ </div>
739
+ `;
740
+ }
741
+ function compactList(region, dismissed) {
742
+ const raw = asRecord(region.payload);
743
+ const title = asString(raw.title);
744
+ return html`
745
+ ${title ? html`<h3 class="group-title">${title}</h3>` : nothing}
746
+ ${productList(visible(readProducts(raw.products), dismissed), region, "tiles tiles-compact")}
747
+ `;
748
+ }
749
+ function contentBlocks(region) {
750
+ const raw = asRecord(region.payload);
751
+ const title = asString(raw.title);
752
+ const blocks = readStrings(raw.blocks);
753
+ const body = asString(raw.body);
754
+ return html`
755
+ ${title ? html`<h3 class="group-title">${title}</h3>` : nothing}
756
+ ${blocks.map((block) => html`<p class="content-block">${block}</p>`)}
757
+ ${body ? html`<p class="content-block">${body}</p>` : nothing}
758
+ `;
759
+ }
760
+ function chatContainer() {
761
+ return html`<div class="chat" data-mount data-module="search:chat"></div>`;
762
+ }
763
+ function renderRegionModule(region, dismissed = NONE_DISMISSED) {
764
+ switch (region.module) {
765
+ case "search:followup-bar":
766
+ return followupBar(region);
767
+ case "search:product-grid":
768
+ return productList(
769
+ visible(readProducts(asRecord(region.payload).products), dismissed),
770
+ region,
771
+ "tiles"
772
+ );
773
+ case "search:group":
774
+ return groupedPicks(region, dismissed);
775
+ case "search:hero":
776
+ return heroProduct(region, dismissed);
777
+ case "search:compare":
778
+ return compareColumn(region, dismissed);
779
+ case "search:shortlist":
780
+ case "search:strip":
781
+ return compactList(region, dismissed);
782
+ case "search:content":
783
+ return contentBlocks(region);
784
+ case "search:chat":
785
+ return chatContainer();
786
+ default:
787
+ return nothing;
788
+ }
789
+ }
790
+
791
+ // src/SearchSurfaceElement.ts
792
+ var SEARCH_SURFACE_TAG = "syntro-search-surface";
793
+ var SEE_STORE_RESULTS_LABEL = "See store results";
794
+ var SKELETON_BLOCKS = {
795
+ query: 0,
796
+ primary: 6,
797
+ secondary: 3,
798
+ support: 2,
799
+ converse: 1
800
+ };
801
+ var SearchSurfaceElement = class extends LitElement {
802
+ constructor() {
803
+ super();
804
+ this._onSeeStoreResults = () => {
805
+ this.renderRoot.dispatchEvent(new CustomEvent(SEE_STORE_RESULTS_EVENT));
806
+ };
807
+ this._variant = null;
808
+ this._query = "";
809
+ this._filled = {};
810
+ this._suppressed = [];
811
+ this._dismissed = /* @__PURE__ */ new Set();
812
+ this._frozen = false;
813
+ this._announcement = "";
814
+ }
815
+ /**
816
+ * Paint the variant's skeleton. Ignored once the surface is frozen, because
817
+ * a server plan that lands after the first content has painted would
818
+ * otherwise throw away what the shopper is already reading.
819
+ */
820
+ showSkeleton(variant, query) {
821
+ if (this._frozen) return;
822
+ this._variant = variant;
823
+ this._query = query;
824
+ this._filled = {};
825
+ this._suppressed = [];
826
+ this._announcement = "";
827
+ }
828
+ /**
829
+ * Fill one region in place. The region keeps its position, so the page does
830
+ * not reflow around the shopper. A region id this variant does not own, or
831
+ * one already retired by `suppressRegion`, has nowhere to paint.
832
+ *
833
+ * Returns whether the region actually painted. The runtime needs that answer
834
+ * to tell "the plan filled a region" from "the plan addressed a variant this
835
+ * surface is not showing": a dropped fill counted as content would leave the
836
+ * shopper on a skeleton the SDK had already scored as a success.
837
+ */
838
+ fillRegion(region) {
839
+ if (!this._variant) return false;
840
+ if (this._suppressed.includes(region.id)) return false;
841
+ if (!skeletonRegions(this._variant).some((slot) => slot.id === region.id)) return false;
842
+ this.freeze();
843
+ this._filled = { ...this._filled, [region.id]: region };
844
+ this._announce();
845
+ return true;
846
+ }
847
+ /** Content this renderer can display, including payload validation and dismissals. */
848
+ getRegionContent(id) {
849
+ const region = this._filled[id];
850
+ return region ? {
851
+ productCount: countRegionProducts(region, this._dismissed),
852
+ hasAnswer: regionHasAnswer(region, this._dismissed)
853
+ } : { productCount: 0, hasAnswer: false };
854
+ }
855
+ /**
856
+ * Retire a slot the plan decided not to fill. The slot leaves the grid
857
+ * entirely — no skeleton, no `aria-busy`, nothing for the live region to
858
+ * count — and every other slot keeps its order. Left in place, a region that
859
+ * is never going to fill pulses beside real results for as long as the
860
+ * shopper reads them.
861
+ *
862
+ * Idempotent, ignored for an id this variant does not own, and allowed after
863
+ * the surface has frozen: suppression is not a repaint, it only removes a
864
+ * slot that was never going to say anything.
865
+ */
866
+ suppressRegion(id) {
867
+ if (this._suppressed.includes(id)) return;
868
+ this._suppressed = [...this._suppressed, id];
869
+ if (this._filled[id]) {
870
+ const { [id]: _removed, ...rest } = this._filled;
871
+ this._filled = rest;
872
+ }
873
+ if (this._frozen) this._announce();
874
+ }
875
+ /**
876
+ * Hide one product, everywhere, for the rest of this surface.
877
+ *
878
+ * Optimistic on purpose: the shopper tapped the tile's own dismiss button,
879
+ * so the tile goes now rather than after a round trip that may legitimately
880
+ * come back with nothing to patch. And it OUTLIVES the payload it was tapped
881
+ * in — a later repaint of the same region, or a whole new plan on a surface
882
+ * with no server session, must not hand back the product just rejected.
883
+ */
884
+ dismissProduct(handle) {
885
+ if (!handle || this._dismissed.has(handle)) return;
886
+ this._dismissed = /* @__PURE__ */ new Set([...this._dismissed, handle]);
887
+ if (this._frozen) this._announce();
888
+ }
889
+ /**
890
+ * Drop one chip from the follow-up bar without waiting for a server repaint.
891
+ *
892
+ * Only reachable when the bar is showing server chips and the surface has no
893
+ * live session to patch — an expired one, in practice. With a session the
894
+ * server repaints the bar and this is never called.
895
+ */
896
+ removeChip(key) {
897
+ const filled = this._filledQueryRegion();
898
+ if (!filled) return;
899
+ const bar = readFollowupBarPayload(filled.payload);
900
+ const chips = bar.chips.filter((chip) => chip.key !== key);
901
+ if (chips.length === bar.chips.length) return;
902
+ this._filled = { ...this._filled, [filled.id]: { ...filled, payload: { ...bar, chips } } };
903
+ }
904
+ /**
905
+ * The container a widget mounts into for this region, or `null` when the
906
+ * region paints no mountable module.
907
+ *
908
+ * The surface renders `search:chat` as an empty box and nothing else: the
909
+ * conversation here is the SDK's own chat widget, mounted by the runtime
910
+ * through its widget registry, never a second chat implementation living in
911
+ * this package. This is the whole affordance that makes that possible.
912
+ */
913
+ mountTarget(regionId) {
914
+ const section = Array.from(this.shadowRoot?.querySelectorAll("[data-region-role]") ?? []).find(
915
+ (node) => node.getAttribute("data-region-id") === regionId
916
+ );
917
+ return section?.querySelector("[data-mount]") ?? null;
918
+ }
919
+ /** The query region as the plan filled it, if the plan filled it at all. */
920
+ _filledQueryRegion() {
921
+ if (!this._variant) return null;
922
+ for (const slot of skeletonRegions(this._variant)) {
923
+ if (slot.role !== "query") continue;
924
+ const filled = this._filled[slot.id];
925
+ if (filled) return filled;
926
+ }
927
+ return null;
928
+ }
929
+ /** Hold the current variant for the rest of this turn. */
930
+ freeze() {
931
+ this._frozen = true;
932
+ }
933
+ /**
934
+ * Only ever called after a region has filled, so "nothing here" is a
935
+ * finished answer, not a loading state. The skeleton is what says "still
936
+ * loading", and it says it visually while the live region stays silent.
937
+ */
938
+ _announce() {
939
+ const total = Object.values(this._filled).reduce(
940
+ (sum, region) => sum + countRegionProducts(region, this._dismissed),
941
+ 0
942
+ );
943
+ const count = total === 0 ? "No results found." : total === 1 ? "1 result" : `${total} results`;
944
+ const query = this._filledQueryRegion();
945
+ const notice = query ? readFollowupBarPayload(query.payload).notice : "";
946
+ this._announcement = notice ? `${notice} ${count}` : count;
947
+ }
948
+ _renderQueryRegion(slot) {
949
+ const filled = this._filled[slot.id];
950
+ const module = filled?.module ?? "search:followup-bar";
951
+ const payload = module === "search:followup-bar" ? (() => {
952
+ const bar = readFollowupBarPayload(filled?.payload);
953
+ return { ...bar, query: bar.query || this._query };
954
+ })() : filled?.payload;
955
+ const region = { id: slot.id, role: slot.role, module, payload };
956
+ return html2`
957
+ <section class="region region-query" data-region-id=${slot.id} data-region-role="query">
958
+ <div class="query-bar">
959
+ ${renderRegionModule(region, this._dismissed)}
960
+ <button
961
+ class="store-results"
962
+ type="button"
963
+ data-action="see-store-results"
964
+ @click=${this._onSeeStoreResults}
965
+ >
966
+ ${SEE_STORE_RESULTS_LABEL}
967
+ </button>
968
+ </div>
969
+ </section>
970
+ `;
971
+ }
972
+ _renderRegion(slot) {
973
+ if (slot.role === "query") return this._renderQueryRegion(slot);
974
+ const filled = this._filled[slot.id];
975
+ return html2`
976
+ <section
977
+ class="region"
978
+ data-region-id=${slot.id}
979
+ data-region-role=${slot.role}
980
+ data-skeleton=${filled ? nothing2 : "true"}
981
+ aria-busy=${filled ? nothing2 : "true"}
982
+ >
983
+ ${filled ? renderRegionModule(filled, this._dismissed) : this._renderSkeleton(slot.role)}
984
+ </section>
985
+ `;
986
+ }
987
+ _renderSkeleton(role) {
988
+ const blocks = Array.from({ length: SKELETON_BLOCKS[role] });
989
+ return html2`
990
+ <div class="skeleton" aria-hidden="true">
991
+ ${blocks.map(() => html2`<div class="skeleton-block"></div>`)}
992
+ </div>
993
+ `;
994
+ }
995
+ render() {
996
+ if (!this._variant) return nothing2;
997
+ const slots = skeletonRegions(this._variant).filter(
998
+ (slot) => !this._suppressed.includes(slot.id)
999
+ );
1000
+ return html2`
1001
+ <div class="surface" data-variant=${this._variant}>
1002
+ <h2 class="sr-only">Search results</h2>
1003
+ <p class="sr-only" role="status" aria-live="polite">${this._announcement}</p>
1004
+ ${repeat(
1005
+ slots,
1006
+ (slot) => slot.id,
1007
+ (slot) => this._renderRegion(slot)
1008
+ )}
1009
+ </div>
1010
+ `;
1011
+ }
1012
+ };
1013
+ SearchSurfaceElement.properties = {
1014
+ _variant: { state: true },
1015
+ _query: { state: true },
1016
+ _filled: { state: true },
1017
+ _suppressed: { state: true },
1018
+ _dismissed: { state: true },
1019
+ _frozen: { state: true },
1020
+ _announcement: { state: true }
1021
+ };
1022
+ SearchSurfaceElement.styles = css`
1023
+ :host {
1024
+ display: block;
1025
+ font-family: var(--sc-font-family, system-ui, -apple-system, sans-serif);
1026
+ color: var(--sc-tile-text-color, var(--sc-content-text-color, #1a1a1a));
1027
+ }
1028
+
1029
+ .surface {
1030
+ display: grid;
1031
+ gap: var(--sc-tile-stack-gap, 1rem);
1032
+ padding: 1rem;
1033
+ box-sizing: border-box;
1034
+ min-height: 100%;
1035
+ background: var(--sc-canvas-background, var(--sc-content-background, #ffffff));
1036
+ }
1037
+
1038
+ .sr-only {
1039
+ position: absolute;
1040
+ width: 1px;
1041
+ height: 1px;
1042
+ margin: -1px;
1043
+ padding: 0;
1044
+ overflow: hidden;
1045
+ clip-path: inset(50%);
1046
+ white-space: nowrap;
1047
+ border: 0;
1048
+ }
1049
+
1050
+ /* Sticky, never fixed. A fixed bar measures itself against the visual
1051
+ viewport, so it jumps when the mobile URL bar animates and hides under
1052
+ the soft keyboard. Sticky inside our own scroll container does not. */
1053
+ .region-query {
1054
+ position: sticky;
1055
+ top: 0;
1056
+ z-index: 1;
1057
+ background: var(--sc-canvas-background, var(--sc-content-background, #ffffff));
1058
+ padding-block: 0.5rem;
1059
+ }
1060
+
1061
+ .region {
1062
+ min-width: 0;
1063
+ }
1064
+
1065
+ .query-bar {
1066
+ display: flex;
1067
+ flex-wrap: wrap;
1068
+ align-items: center;
1069
+ gap: 0.5rem;
1070
+ }
1071
+
1072
+ .followup {
1073
+ display: flex;
1074
+ flex-wrap: wrap;
1075
+ align-items: center;
1076
+ gap: 0.5rem;
1077
+ flex: 1 1 14rem;
1078
+ min-width: 0;
1079
+ }
1080
+
1081
+ .followup-input {
1082
+ flex: 1 1 12rem;
1083
+ min-width: 0;
1084
+ min-height: 2.5rem;
1085
+ padding: 0.5rem 0.75rem;
1086
+ font: inherit;
1087
+ color: inherit;
1088
+ border: 1px solid var(--sc-tile-border, rgba(0, 0, 0, 0.2));
1089
+ border-radius: var(--sc-border-radius, 0.5rem);
1090
+ background: var(--sc-content-search-background, transparent);
1091
+ }
1092
+
1093
+ .followup-notice {
1094
+ flex: 1 1 100%;
1095
+ margin: 0;
1096
+ font-size: var(--sc-tile-subtitle-size, 0.875rem);
1097
+ color: var(--sc-content-text-secondary-color, inherit);
1098
+ }
1099
+
1100
+ .chips,
1101
+ .suggestions {
1102
+ display: flex;
1103
+ flex-wrap: wrap;
1104
+ gap: 0.5rem;
1105
+ }
1106
+
1107
+ .chip,
1108
+ .suggestion,
1109
+ .store-results {
1110
+ display: inline-flex;
1111
+ align-items: center;
1112
+ gap: 0.25rem;
1113
+ min-height: 1.75rem;
1114
+ min-width: 1.75rem;
1115
+ padding: 0.25rem 0.75rem;
1116
+ font: inherit;
1117
+ cursor: pointer;
1118
+ border-radius: var(--sc-border-radius, 0.5rem);
1119
+ border: 1px solid var(--sc-chip-border, rgba(0, 0, 0, 0.2));
1120
+ background: var(--sc-chip-background, transparent);
1121
+ color: var(--sc-chip-foreground, inherit);
1122
+ }
1123
+
1124
+ .store-results {
1125
+ margin-inline-start: auto;
1126
+ border-color: var(--sc-color-primary, currentColor);
1127
+ color: var(--sc-color-primary, inherit);
1128
+ background: transparent;
1129
+ }
1130
+
1131
+ .tiles {
1132
+ display: grid;
1133
+ gap: var(--sc-tile-gap, 0.75rem);
1134
+ }
1135
+
1136
+ .tile {
1137
+ position: relative;
1138
+ min-width: 0;
1139
+ border: 1px solid var(--sc-tile-border, rgba(0, 0, 0, 0.12));
1140
+ border-radius: var(--sc-tile-border-radius, var(--sc-border-radius, 0.5rem));
1141
+ background: var(--sc-tile-background, transparent);
1142
+ }
1143
+
1144
+ .tile-link {
1145
+ display: flex;
1146
+ flex-direction: column;
1147
+ gap: 0.25rem;
1148
+ padding: var(--sc-tile-body-padding, 0.75rem);
1149
+ color: inherit;
1150
+ text-decoration: none;
1151
+ }
1152
+
1153
+ .tile-image {
1154
+ width: 100%;
1155
+ max-width: 100%;
1156
+ aspect-ratio: 1 / 1;
1157
+ object-fit: cover;
1158
+ border-radius: inherit;
1159
+ }
1160
+
1161
+ .tile-title {
1162
+ font-size: var(--sc-tile-title-size, 0.9375rem);
1163
+ font-weight: var(--sc-tile-title-weight, 600);
1164
+ color: var(--sc-tile-title-color, inherit);
1165
+ }
1166
+
1167
+ .tile-price {
1168
+ font-size: var(--sc-tile-subtitle-size, 0.875rem);
1169
+ }
1170
+
1171
+ .tile-dismiss {
1172
+ position: absolute;
1173
+ inset-block-start: 0.25rem;
1174
+ inset-inline-end: 0.25rem;
1175
+ display: inline-flex;
1176
+ align-items: center;
1177
+ justify-content: center;
1178
+ min-width: 1.75rem;
1179
+ min-height: 1.75rem;
1180
+ padding: 0;
1181
+ font: inherit;
1182
+ cursor: pointer;
1183
+ border: 0;
1184
+ border-radius: 50%;
1185
+ color: var(--sc-tile-dismiss-color, inherit);
1186
+ background: var(--sc-tile-dismiss-background, transparent);
1187
+ }
1188
+
1189
+ .group-title,
1190
+ .hero-reason,
1191
+ .content-block {
1192
+ margin: 0 0 0.5rem;
1193
+ }
1194
+
1195
+ .group-title {
1196
+ font-size: var(--sc-tile-title-size, 0.9375rem);
1197
+ font-weight: 600;
1198
+ }
1199
+
1200
+ .compare-attributes {
1201
+ margin: 0.5rem 0 0;
1202
+ padding-inline-start: 1.1em;
1203
+ }
1204
+
1205
+ .skeleton {
1206
+ display: grid;
1207
+ gap: var(--sc-tile-gap, 0.75rem);
1208
+ }
1209
+
1210
+ .skeleton-block {
1211
+ height: 4rem;
1212
+ border-radius: var(--sc-tile-border-radius, var(--sc-border-radius, 0.5rem));
1213
+ background: var(--sc-tile-background, rgba(0, 0, 0, 0.06));
1214
+ opacity: 0.6;
1215
+ animation: syntro-search-pulse 1.4s ease-in-out infinite;
1216
+ }
1217
+
1218
+ @keyframes syntro-search-pulse {
1219
+ 50% {
1220
+ opacity: 0.25;
1221
+ }
1222
+ }
1223
+
1224
+ :focus-visible {
1225
+ outline: 2px solid var(--sc-color-primary, currentColor);
1226
+ outline-offset: 2px;
1227
+ }
1228
+
1229
+ @media (prefers-reduced-motion: reduce) {
1230
+ .skeleton-block {
1231
+ animation: none;
1232
+ }
1233
+ }
1234
+
1235
+ @media (forced-colors: active) {
1236
+ .tile,
1237
+ .chip,
1238
+ .suggestion,
1239
+ .store-results,
1240
+ .followup-input {
1241
+ border: 1px solid CanvasText;
1242
+ }
1243
+ .skeleton-block {
1244
+ border: 1px solid CanvasText;
1245
+ background: Canvas;
1246
+ }
1247
+ }
1248
+
1249
+ /* Every variant is a single column below this width. Above it, each one
1250
+ takes the shape the plan chose. */
1251
+ @media (min-width: 641px) {
1252
+ .tiles {
1253
+ grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
1254
+ }
1255
+
1256
+ .tiles-compact {
1257
+ grid-template-columns: minmax(0, 1fr);
1258
+ }
1259
+
1260
+ .surface[data-variant='rail'] {
1261
+ grid-template-columns: minmax(0, 16rem) minmax(0, 1fr);
1262
+ }
1263
+
1264
+ .surface[data-variant='rail'] > [data-region-id='query-1'],
1265
+ .surface[data-variant='rail'] > [data-region-id='converse-1'] {
1266
+ grid-column: 1 / -1;
1267
+ }
1268
+
1269
+ .surface[data-variant='rail'] > [data-region-id='secondary-1'] {
1270
+ grid-column: 1;
1271
+ grid-row: 2 / span 2;
1272
+ }
1273
+
1274
+ .surface[data-variant='rail'] > [data-region-id='primary-1'] {
1275
+ grid-column: 2;
1276
+ grid-row: 2;
1277
+ }
1278
+
1279
+ .surface[data-variant='rail'] > [data-region-id='support-1'] {
1280
+ grid-column: 2;
1281
+ grid-row: 3;
1282
+ }
1283
+
1284
+ .surface[data-variant='compare'] {
1285
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1286
+ }
1287
+
1288
+ .surface[data-variant='compare'] > [data-region-id='query-1'],
1289
+ .surface[data-variant='compare'] > [data-region-id='secondary-1'],
1290
+ .surface[data-variant='compare'] > [data-region-id='converse-1'] {
1291
+ grid-column: 1 / -1;
1292
+ }
1293
+
1294
+ .surface[data-variant='compare'] > [data-region-id='primary-1'] {
1295
+ grid-column: 1;
1296
+ }
1297
+
1298
+ .surface[data-variant='compare'] > [data-region-id='primary-2'] {
1299
+ grid-column: 2;
1300
+ }
1301
+
1302
+ .surface[data-variant='hero'] > [data-region-id='secondary-1'] .tiles {
1303
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1304
+ }
1305
+ }
1306
+ `;
1307
+ function registerSearchSurfaceElement() {
1308
+ if (typeof customElements === "undefined") return;
1309
+ if (!customElements.get(SEARCH_SURFACE_TAG)) {
1310
+ customElements.define(SEARCH_SURFACE_TAG, SearchSurfaceElement);
1311
+ }
1312
+ }
1313
+
1314
+ // src/runtime.ts
1315
+ registerSearchSurfaceElement();
1316
+ var SearchSurfaceMountable = {
1317
+ mount(container, config) {
1318
+ const surfaceConfig = stripMountPlumbing(config ?? null);
1319
+ const element = document.createElement("syntro-search-surface");
1320
+ container.appendChild(element);
1321
+ if (surfaceConfig?.variant) {
1322
+ element.showSkeleton(surfaceConfig.variant, surfaceConfig.query ?? "");
1323
+ }
1324
+ return () => element.remove();
1325
+ }
1326
+ };
1327
+ var runtime = {
1328
+ id: "adaptive-search",
1329
+ version: "1.0.0",
1330
+ name: "Adaptive Search",
1331
+ description: "Draws every search result as a Syntro surface.",
1332
+ /** No DOM-mutation executors: this surface renders only. */
1333
+ executors: [],
1334
+ widgets: [
1335
+ {
1336
+ id: "adaptive-search:surface",
1337
+ component: SearchSurfaceMountable,
1338
+ metadata: {
1339
+ name: "Search Surface",
1340
+ description: "The search results surface, painted per variant and filled region by region",
1341
+ icon: "\u{1F50E}",
1342
+ /**
1343
+ * The surface replaces a whole results page, so it needs the
1344
+ * full-viewport slot. It is not self-sufficient anywhere else: the
1345
+ * scrolling and scroll containment it relies on come from
1346
+ * `overlay_full`'s own slot styles (`overflow: auto` plus
1347
+ * `overscroll-behavior: contain`), not from this element.
1348
+ */
1349
+ slots: ["overlay_full"]
1350
+ }
1351
+ }
1352
+ ]
1353
+ };
1354
+ var runtime_default = runtime;
1355
+
1356
+ export {
1357
+ SearchSurfaceMountable,
1358
+ runtime,
1359
+ runtime_default
1360
+ };
1361
+ /*! Bundled license information:
1362
+
1363
+ @lit/context/lib/context-request-event.js:
1364
+ @lit/context/lib/create-context.js:
1365
+ @lit/context/lib/controllers/context-consumer.js:
1366
+ @lit/context/lib/value-notifier.js:
1367
+ @lit/context/lib/controllers/context-provider.js:
1368
+ @lit/context/lib/context-root.js:
1369
+ (**
1370
+ * @license
1371
+ * Copyright 2021 Google LLC
1372
+ * SPDX-License-Identifier: BSD-3-Clause
1373
+ *)
1374
+
1375
+ @lit/context/lib/decorators/provide.js:
1376
+ (**
1377
+ * @license
1378
+ * Copyright 2017 Google LLC
1379
+ * SPDX-License-Identifier: BSD-3-Clause
1380
+ *)
1381
+
1382
+ @lit/context/lib/decorators/consume.js:
1383
+ (**
1384
+ * @license
1385
+ * Copyright 2022 Google LLC
1386
+ * SPDX-License-Identifier: BSD-3-Clause
1387
+ *)
1388
+ */
1389
+ //# sourceMappingURL=chunk-3KPQ5AOG.js.map