@sensiblestats/widget-react 0.12.0 → 0.13.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/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # @sensiblestats/widget-react
2
2
 
3
+ ## 0.13.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Odds movement price chart card (SP4 widget half).
8
+
9
+ - `widget-sdk`: `odds_movement` chat event — `OddsMovementCard`, `OddsMovementPoint`, `OddsMovementEventData`.
10
+ - `widget-react`: renders the card — a time-proportional inline SVG price chart with bookmaker, direction badge, suspended state and excluded-observation disclosure.
11
+ - `widget-embed`: re-bundles `widget-react`, so it ships the new card too.
12
+
13
+ ### Patch Changes
14
+
15
+ - Updated dependencies
16
+ - @sensiblestats/widget-sdk@0.11.0
17
+
3
18
  ## 0.12.0
4
19
 
5
20
  ### Minor Changes
package/dist/index.cjs CHANGED
@@ -65,6 +65,19 @@ var STRINGS = {
65
65
  oddsShortened: "shortened",
66
66
  oddsDrifted: "drifted",
67
67
  updatedLabel: "updated",
68
+ priceMovement: "Price movement",
69
+ openingLabel: "Opening",
70
+ currentLabel: "Current",
71
+ movementWindow: (hours) => hours % 24 === 0 ? `Last ${hours / 24}d` : `Last ${hours}h`,
72
+ notEnoughHistory: "No price history recorded yet for this selection.",
73
+ singlePricePoint: "Only one price recorded so far for this selection.",
74
+ selectionSuspended: "This selection is not currently available to bet.",
75
+ excludedSuspended: (n) => `${n} suspended price${n === 1 ? "" : "s"} excluded from this chart.`,
76
+ chartTimeLabel: (ms) => new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }).format(new Date(ms)),
77
+ chartAria: (opening, current, direction, hours) => {
78
+ const span = hours % 24 === 0 ? `${hours / 24} day${hours / 24 === 1 ? "" : "s"}` : `${hours} hour${hours === 1 ? "" : "s"}`;
79
+ return `Price movement over the last ${span}: opened at ${opening}, currently ${current}, ${direction}.`;
80
+ },
68
81
  inSlip: "In slip",
69
82
  slipAdded: (n) => n === void 0 ? "Added to slip" : `Added to slip (${n} selection${n === 1 ? "" : "s"})`,
70
83
  slipDuplicate: "Already in your slip",
@@ -98,6 +111,19 @@ var STRINGS = {
98
111
  oddsShortened: "d\xFC\u015Ft\xFC",
99
112
  oddsDrifted: "y\xFCkseldi",
100
113
  updatedLabel: "g\xFCncellendi",
114
+ priceMovement: "Oran hareketi",
115
+ openingLabel: "A\xE7\u0131l\u0131\u015F",
116
+ currentLabel: "G\xFCncel",
117
+ movementWindow: (hours) => hours % 24 === 0 ? `Son ${hours / 24} g\xFCn` : `Son ${hours} saat`,
118
+ notEnoughHistory: "Bu se\xE7im i\xE7in hen\xFCz oran ge\xE7mi\u015Fi kaydedilmedi.",
119
+ singlePricePoint: "Bu se\xE7im i\xE7in \u015Fimdiye kadar yaln\u0131zca bir fiyat kaydedildi.",
120
+ selectionSuspended: "Bu se\xE7im \u015Fu anda bahse kapal\u0131.",
121
+ excludedSuspended: (n) => `${n} ask\u0131ya al\u0131nm\u0131\u015F fiyat bu grafikten hari\xE7 tutuldu.`,
122
+ chartTimeLabel: (ms) => new Intl.DateTimeFormat("tr-TR", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }).format(new Date(ms)),
123
+ chartAria: (opening, current, direction, hours) => {
124
+ const span = hours % 24 === 0 ? `${hours / 24} g\xFCn` : `${hours} saat`;
125
+ return `Son ${span} i\xE7indeki oran hareketi: ${opening} ile a\xE7\u0131ld\u0131, \u015Fu anda ${current}, ${direction}.`;
126
+ },
101
127
  inSlip: "Kuponda",
102
128
  slipAdded: (n) => n === void 0 ? "Kupona eklendi" : `Kupona eklendi (${n} se\xE7im)`,
103
129
  slipDuplicate: "Bu se\xE7im kuponunuzda zaten var",
@@ -147,6 +173,16 @@ function normalizeUi(cfg) {
147
173
  oddsShortened: strings.oddsShortened,
148
174
  oddsDrifted: strings.oddsDrifted,
149
175
  updatedLabel: strings.updatedLabel,
176
+ priceMovement: strings.priceMovement,
177
+ openingLabel: strings.openingLabel,
178
+ currentLabel: strings.currentLabel,
179
+ movementWindow: strings.movementWindow,
180
+ notEnoughHistory: strings.notEnoughHistory,
181
+ singlePricePoint: strings.singlePricePoint,
182
+ selectionSuspended: strings.selectionSuspended,
183
+ excludedSuspended: strings.excludedSuspended,
184
+ chartTimeLabel: strings.chartTimeLabel,
185
+ chartAria: strings.chartAria,
150
186
  addToSlip: strings.addToSlip,
151
187
  inSlip: strings.inSlip,
152
188
  slipAdded: strings.slipAdded,
@@ -185,6 +221,54 @@ var import_widget_sdk2 = require("@sensiblestats/widget-sdk");
185
221
  // src/reducer.ts
186
222
  var import_widget_sdk = require("@sensiblestats/widget-sdk");
187
223
 
224
+ // src/chart.ts
225
+ var CHART_VIEW_W = 320;
226
+ var CHART_VIEW_H = 140;
227
+ var PLOT_X0 = 40;
228
+ var PLOT_X1 = 312;
229
+ var PLOT_Y0 = 10;
230
+ var PLOT_Y1 = 110;
231
+ var MARKER_MAX_POINTS = 8;
232
+ var FLAT_DOMAIN_PAD = 0.05;
233
+ var round2 = (n) => Math.round(n * 100) / 100;
234
+ function projectSeries(samples) {
235
+ if (samples.length < 2) return null;
236
+ const tMin = samples[0].atMs;
237
+ const tMax = samples[samples.length - 1].atMs;
238
+ if (tMax <= tMin) return null;
239
+ const prices = samples.map((s) => s.price);
240
+ const pMin = Math.min(...prices);
241
+ const pMax = Math.max(...prices);
242
+ let yMin;
243
+ let yMax;
244
+ if (pMax === pMin) {
245
+ yMin = pMin - FLAT_DOMAIN_PAD;
246
+ yMax = pMax + FLAT_DOMAIN_PAD;
247
+ } else {
248
+ const pad = (pMax - pMin) * 0.1;
249
+ yMin = pMin - pad;
250
+ yMax = pMax + pad;
251
+ }
252
+ const x = (atMs) => round2(PLOT_X0 + (atMs - tMin) / (tMax - tMin) * (PLOT_X1 - PLOT_X0));
253
+ const y = (price) => round2(PLOT_Y1 - (price - yMin) / (yMax - yMin) * (PLOT_Y1 - PLOT_Y0));
254
+ const projected = samples.map((s) => ({ x: x(s.atMs), y: y(s.price) }));
255
+ const path = projected.map((p, i) => `${i === 0 ? "M" : "L"}${p.x} ${p.y}`).join(" ");
256
+ return {
257
+ path,
258
+ markers: samples.length <= MARKER_MAX_POINTS ? projected : [],
259
+ // The real extremes, never the padded bounds: a gridline label is a price, and the padded
260
+ // bounds are prices nobody was ever offered. A flat series has one real extreme, not two —
261
+ // two identical gridlines would collide on both React key and rendered text.
262
+ gridlines: pMax === pMin ? [{ y: y(pMax), price: pMax }] : [
263
+ { y: y(pMax), price: pMax },
264
+ { y: y(pMin), price: pMin }
265
+ ],
266
+ viewBox: `0 0 ${CHART_VIEW_W} ${CHART_VIEW_H}`,
267
+ startMs: tMin,
268
+ endMs: tMax
269
+ };
270
+ }
271
+
188
272
  // src/model.ts
189
273
  function str(v) {
190
274
  return typeof v === "string" && v.length > 0 ? v : void 0;
@@ -327,6 +411,41 @@ function toMarketOddsVM(card) {
327
411
  addToSlipEnabled: card.addToSlipEnabled === true
328
412
  };
329
413
  }
414
+ function toOddsMovementVM(card) {
415
+ const raw = Array.isArray(card.points) ? card.points : [];
416
+ const samples = [];
417
+ for (const p of raw) {
418
+ const price = typeof p.price === "number" && Number.isFinite(p.price) ? p.price : void 0;
419
+ const atMs = typeof p.atUtc === "string" ? Date.parse(p.atUtc) : NaN;
420
+ if (price === void 0 || Number.isNaN(atMs)) continue;
421
+ samples.push({ atMs, price });
422
+ }
423
+ samples.sort((a, b) => a.atMs - b.atMs);
424
+ const asNumber = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
425
+ const opening = asNumber(card.openingPrice);
426
+ const current = asNumber(card.currentPrice);
427
+ const home = str(card.homeTeamName);
428
+ const away = str(card.awayTeamName);
429
+ const selection = [str(card.selectionLabel), str(card.line)].filter(Boolean).join(" ");
430
+ const bookmakerId = typeof card.bookmakerId === "number" ? card.bookmakerId : "";
431
+ return {
432
+ id: `${typeof card.matchId === "number" ? card.matchId : 0}:${str(card.marketDeveloperName) ?? ""}:${selection}:${bookmakerId}`,
433
+ market: str(card.marketLabel) ?? str(card.marketDeveloperName) ?? "",
434
+ selection,
435
+ fixture: home && away ? `${home} vs ${away}` : void 0,
436
+ competition: str(card.competitionName),
437
+ bookmaker: str(card.bookmakerName),
438
+ opening: opening !== void 0 ? opening.toFixed(2) : "",
439
+ current: current !== void 0 ? current.toFixed(2) : "",
440
+ direction: card.movementDirection === "shortened" || card.movementDirection === "drifted" ? card.movementDirection : void 0,
441
+ windowHours: asNumber(card.windowHours) ?? 0,
442
+ excludedCount: asNumber(card.excludedSuspendedCount) ?? 0,
443
+ isSuspended: card.isSuspended === true,
444
+ disclaimer: str(card.disclaimer),
445
+ geometry: projectSeries(samples),
446
+ observationCount: samples.length
447
+ };
448
+ }
330
449
  function marketOddsRowToSlipSelection(vm, row, conversationId) {
331
450
  if (row.matchOddsId === void 0 || row.oddsDecimal === void 0) return null;
332
451
  const selection = { matchOddsId: row.matchOddsId, matchId: vm.matchId, oddsDecimal: row.oddsDecimal };
@@ -367,6 +486,8 @@ function applyEvent(state, event) {
367
486
  return { ...state, messages: mapActive(state, (t) => ({ ...t, insights: [...t.insights, toInsightVM(event.data.insight)] })) };
368
487
  case "market_odds":
369
488
  return { ...state, messages: mapActive(state, (t) => ({ ...t, marketOdds: [...t.marketOdds, toMarketOddsVM(event.data.card)] })) };
489
+ case "odds_movement":
490
+ return { ...state, messages: mapActive(state, (t) => ({ ...t, oddsMovement: [...t.oddsMovement, toOddsMovementVM(event.data.card)] })) };
370
491
  case "intent_chip": {
371
492
  const chip = toChipVM(event.data.intent);
372
493
  return chip ? { ...state, starters: [...state.starters, chip] } : state;
@@ -402,11 +523,11 @@ function reducer(state, action) {
402
523
  return { ...state, isOpen: false, isStreaming: false, status: null, actionsLoading: false };
403
524
  case "USER_SENT": {
404
525
  const user = { role: "user", id: action.userId, text: action.displayText ?? action.input.message };
405
- const assistant = { role: "assistant", id: action.assistantId, text: "", cards: [], actions: [], insights: [], marketOdds: [], done: false };
526
+ const assistant = { role: "assistant", id: action.assistantId, text: "", cards: [], actions: [], insights: [], marketOdds: [], oddsMovement: [], done: false };
406
527
  return { ...state, messages: [...state.messages, user, assistant], isStreaming: true, status: null, actionsLoading: false, starters: [], error: null, lastUserInput: action.input, lastDisplayText: action.displayText ?? null };
407
528
  }
408
529
  case "GREETING_SENT": {
409
- const assistant = { role: "assistant", id: action.assistantId, text: "", cards: [], actions: [], insights: [], marketOdds: [], done: false, isGreeting: true };
530
+ const assistant = { role: "assistant", id: action.assistantId, text: "", cards: [], actions: [], insights: [], marketOdds: [], oddsMovement: [], done: false, isGreeting: true };
410
531
  return { ...state, messages: [...state.messages, assistant], isStreaming: true, status: null, error: null };
411
532
  }
412
533
  case "DISMISS_POPUP":
@@ -776,8 +897,8 @@ function OddsMovementBadge({
776
897
  ui
777
898
  }) {
778
899
  if (previous === void 0 || direction === void 0) return null;
779
- const directionWord = direction === "shortened" ? ui?.oddsShortened ?? "shortened" : ui?.oddsDrifted ?? "drifted";
780
- const label = ui?.oddsMoved?.(previous, current, directionWord) ?? `was ${previous}, now ${current}, ${directionWord}`;
900
+ const directionWord2 = direction === "shortened" ? ui?.oddsShortened ?? "shortened" : ui?.oddsDrifted ?? "drifted";
901
+ const label = ui?.oddsMoved?.(previous, current, directionWord2) ?? `was ${previous}, now ${current}, ${directionWord2}`;
781
902
  return /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("span", { className: "ss-w-move", role: "img", "aria-label": label, children: [
782
903
  /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("span", { className: "ss-w-move-prev", "aria-hidden": "true", children: previous }),
783
904
  /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("span", { className: "ss-w-move-arrow", "aria-hidden": "true", children: direction === "shortened" ? "\u25BC" : "\u25B2" })
@@ -898,39 +1019,105 @@ function MarketOddsCardList({ cards, ui, slip }) {
898
1019
  return /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("div", { className: "ss-w-mocards", children: cards.map((vm) => /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(MarketOddsCard, { vm, ui, slip }, vm.id)) });
899
1020
  }
900
1021
 
901
- // src/components/ActionButtons.tsx
1022
+ // src/components/OddsMovementCard.tsx
902
1023
  var import_jsx_runtime12 = require("react/jsx-runtime");
1024
+ function directionWord(vm, ui) {
1025
+ if (vm.direction === "shortened") return ui?.oddsShortened ?? "shortened";
1026
+ if (vm.direction === "drifted") return ui?.oddsDrifted ?? "drifted";
1027
+ return "";
1028
+ }
1029
+ function OddsMovementCard({ vm, ui }) {
1030
+ const aria = ui?.chartAria ? ui.chartAria(vm.opening, vm.current, directionWord(vm, ui), vm.windowHours) : `Price movement over the last ${vm.windowHours} hours: opened at ${vm.opening}, currently ${vm.current}, ${directionWord(vm, ui)}.`;
1031
+ return /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)("div", { className: "ss-w-omcard", children: [
1032
+ /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)("div", { className: "ss-w-om-head", children: [
1033
+ /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("span", { className: "ss-w-om-market", children: vm.market }),
1034
+ /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("span", { className: "ss-w-om-tag", children: ui?.priceMovement ?? "Price movement" })
1035
+ ] }),
1036
+ vm.fixture ? /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)("div", { className: "ss-w-om-fixture", children: [
1037
+ /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("span", { children: vm.fixture }),
1038
+ vm.competition ? /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("span", { className: "ss-w-om-comp", children: vm.competition }) : null
1039
+ ] }) : null,
1040
+ /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("div", { className: "ss-w-om-sel", children: vm.selection }),
1041
+ /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(OddsMovementBadge, { previous: vm.opening, current: vm.current, direction: vm.direction, ui }),
1042
+ vm.geometry === null ? /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("div", { className: "ss-w-om-empty", children: vm.observationCount === 1 ? ui?.singlePricePoint ?? "Only one price recorded so far for this selection." : ui?.notEnoughHistory ?? "No price history recorded yet for this selection." }) : /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)(
1043
+ "svg",
1044
+ {
1045
+ className: "ss-w-om-chart",
1046
+ viewBox: vm.geometry.viewBox,
1047
+ width: "100%",
1048
+ role: "img",
1049
+ "aria-label": aria,
1050
+ children: [
1051
+ vm.geometry.gridlines.map((g) => /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)("g", { children: [
1052
+ /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("line", { className: "ss-w-om-grid", x1: 40, y1: g.y, x2: CHART_VIEW_W - 8, y2: g.y }),
1053
+ /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("text", { className: "ss-w-om-ylab", x: 34, y: g.y + 3, textAnchor: "end", children: g.price.toFixed(2) })
1054
+ ] }, g.price)),
1055
+ /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("path", { className: "ss-w-om-line", d: vm.geometry.path, fill: "none" }),
1056
+ vm.geometry.markers.map((m, i) => /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("circle", { className: "ss-w-om-dot", cx: m.x, cy: m.y, r: 2.5 }, i)),
1057
+ ui?.chartTimeLabel ? /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)(import_jsx_runtime12.Fragment, { children: [
1058
+ /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("text", { className: "ss-w-om-xlab", x: 40, y: CHART_VIEW_H - 8, textAnchor: "start", children: ui.chartTimeLabel(vm.geometry.startMs) }),
1059
+ /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("text", { className: "ss-w-om-xlab", x: CHART_VIEW_W - 8, y: CHART_VIEW_H - 8, textAnchor: "end", children: ui.chartTimeLabel(vm.geometry.endMs) })
1060
+ ] }) : /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("text", { className: "ss-w-om-xlab", x: 40, y: CHART_VIEW_H - 8, children: ui?.movementWindow ? ui.movementWindow(vm.windowHours) : `Last ${vm.windowHours}h` })
1061
+ ]
1062
+ }
1063
+ ),
1064
+ /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)("div", { className: "ss-w-om-foot", children: [
1065
+ /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)("span", { children: [
1066
+ ui?.openingLabel ?? "Opening",
1067
+ " ",
1068
+ /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("b", { children: vm.opening })
1069
+ ] }),
1070
+ /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)("span", { children: [
1071
+ ui?.currentLabel ?? "Current",
1072
+ " ",
1073
+ /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("b", { children: vm.current })
1074
+ ] })
1075
+ ] }),
1076
+ vm.bookmaker ? /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("div", { className: "ss-w-om-foot", children: /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("span", { children: vm.bookmaker }) }) : null,
1077
+ vm.excludedCount > 0 ? /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("div", { className: "ss-w-om-excl", children: ui?.excludedSuspended ? ui.excludedSuspended(vm.excludedCount) : `${vm.excludedCount} suspended price(s) excluded from this chart.` }) : null,
1078
+ vm.isSuspended ? /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("div", { className: "ss-w-om-susp", children: ui?.selectionSuspended ?? "This selection is not currently available to bet." }) : null,
1079
+ vm.disclaimer ? /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("div", { className: "ss-w-om-disc", children: vm.disclaimer }) : null
1080
+ ] });
1081
+ }
1082
+ function OddsMovementCardList({ cards, ui }) {
1083
+ if (!cards || cards.length === 0) return null;
1084
+ return /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("div", { className: "ss-w-omcards", children: cards.map((vm) => /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(OddsMovementCard, { vm, ui }, vm.id)) });
1085
+ }
1086
+
1087
+ // src/components/ActionButtons.tsx
1088
+ var import_jsx_runtime13 = require("react/jsx-runtime");
903
1089
  function ActionButtons({ actions, loading, disabled, onAct }) {
904
1090
  if (actions.length === 0 && !loading) return null;
905
- return /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)("div", { className: "ss-w-actions", children: [
906
- actions.map((a, i) => /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("button", { type: "button", className: "ss-w-abtn", disabled, onClick: () => onAct(a), children: a.label }, `${a.label}-${i}`)),
907
- actions.length === 0 && loading ? /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)(import_jsx_runtime12.Fragment, { children: [
908
- /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("span", { className: "ss-w-abtn-shimmer" }),
909
- /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("span", { className: "ss-w-abtn-shimmer" })
1091
+ return /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("div", { className: "ss-w-actions", children: [
1092
+ actions.map((a, i) => /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("button", { type: "button", className: "ss-w-abtn", disabled, onClick: () => onAct(a), children: a.label }, `${a.label}-${i}`)),
1093
+ actions.length === 0 && loading ? /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)(import_jsx_runtime13.Fragment, { children: [
1094
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("span", { className: "ss-w-abtn-shimmer" }),
1095
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("span", { className: "ss-w-abtn-shimmer" })
910
1096
  ] }) : null
911
1097
  ] });
912
1098
  }
913
1099
 
914
1100
  // src/components/ChatMessage.tsx
915
- var import_jsx_runtime13 = require("react/jsx-runtime");
1101
+ var import_jsx_runtime14 = require("react/jsx-runtime");
916
1102
  function ChatMessage({ message, disabled, onAct, ui, slip }) {
917
1103
  if (message.role === "user") {
918
- return /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("div", { className: "ss-w-msg ss-w-user", children: /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("div", { className: "ss-w-bubble", children: message.text }) });
1104
+ return /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { className: "ss-w-msg ss-w-user", children: /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { className: "ss-w-bubble", children: message.text }) });
919
1105
  }
920
- return /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("div", { className: "ss-w-msg ss-w-bot", children: [
921
- message.text ? /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(Markdown, { text: message.text }) : null,
922
- /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(EntityCardList, { cards: message.cards, disabled, showLessLabel: ui?.showLess, showMoreLabel: ui?.showMore }),
923
- /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(BettingInsightList, { insights: message.insights, ui, slip }),
924
- /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(MarketOddsCardList, { cards: message.marketOdds, ui, slip }),
925
- /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(ActionButtons, { actions: message.actions, loading: false, disabled, onAct })
1106
+ return /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("div", { className: "ss-w-msg ss-w-bot", children: [
1107
+ message.text ? /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(Markdown, { text: message.text }) : null,
1108
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(EntityCardList, { cards: message.cards, disabled, showLessLabel: ui?.showLess, showMoreLabel: ui?.showMore }),
1109
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(BettingInsightList, { insights: message.insights, ui, slip }),
1110
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(MarketOddsCardList, { cards: message.marketOdds, ui, slip }),
1111
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(OddsMovementCardList, { cards: message.oddsMovement, ui }),
1112
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(ActionButtons, { actions: message.actions, loading: false, disabled, onAct })
926
1113
  ] });
927
1114
  }
928
1115
 
929
1116
  // src/components/IntentChips.tsx
930
- var import_jsx_runtime14 = require("react/jsx-runtime");
1117
+ var import_jsx_runtime15 = require("react/jsx-runtime");
931
1118
  function IntentChips({ chips, onPick }) {
932
1119
  if (chips.length === 0) return null;
933
- return /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { className: "ss-w-chips", children: chips.map((c, i) => /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("button", { type: "button", className: "ss-w-chip", onClick: () => onPick(c.text), children: c.text }, `${c.text}-${i}`)) });
1120
+ return /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("div", { className: "ss-w-chips", children: chips.map((c, i) => /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("button", { type: "button", className: "ss-w-chip", onClick: () => onPick(c.text), children: c.text }, `${c.text}-${i}`)) });
934
1121
  }
935
1122
 
936
1123
  // src/scroll.ts
@@ -940,7 +1127,7 @@ function isPinnedToBottom(el, threshold = PIN_THRESHOLD) {
940
1127
  }
941
1128
 
942
1129
  // src/components/MessageList.tsx
943
- var import_jsx_runtime15 = require("react/jsx-runtime");
1130
+ var import_jsx_runtime16 = require("react/jsx-runtime");
944
1131
  function MessageList({ controller, ui, slip }) {
945
1132
  const { state, send } = controller;
946
1133
  const listRef = (0, import_react7.useRef)(null);
@@ -950,7 +1137,7 @@ function MessageList({ controller, ui, slip }) {
950
1137
  if (pinnedRef.current) endRef.current?.scrollIntoView({ block: "end" });
951
1138
  }, [state.messages, state.status]);
952
1139
  const empty = state.messages.length === 0;
953
- return /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)(
1140
+ return /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(
954
1141
  "div",
955
1142
  {
956
1143
  className: "ss-w-list",
@@ -960,7 +1147,7 @@ function MessageList({ controller, ui, slip }) {
960
1147
  if (listRef.current) pinnedRef.current = isPinnedToBottom(listRef.current);
961
1148
  },
962
1149
  children: [
963
- state.messages.map((m) => /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
1150
+ state.messages.map((m) => /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
964
1151
  ChatMessage,
965
1152
  {
966
1153
  message: m,
@@ -971,15 +1158,15 @@ function MessageList({ controller, ui, slip }) {
971
1158
  },
972
1159
  m.id
973
1160
  )),
974
- state.isStreaming && state.actionsLoading ? /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(ActionButtons, { actions: [], loading: true, onAct: () => {
1161
+ state.isStreaming && state.actionsLoading ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(ActionButtons, { actions: [], loading: true, onAct: () => {
975
1162
  } }) : null,
976
- state.status ? /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("div", { className: "ss-w-status", children: state.status }) : null,
977
- state.error ? /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { className: "ss-w-error", role: "alert", children: [
978
- /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("span", { children: "message" in state.error ? state.error.message : ui.somethingWentWrong }),
979
- /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("button", { type: "button", className: "ss-w-retry", onClick: () => controller.retry(), children: ui.retry })
1163
+ state.status ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { className: "ss-w-status", children: state.status }) : null,
1164
+ state.error ? /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { className: "ss-w-error", role: "alert", children: [
1165
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { children: "message" in state.error ? state.error.message : ui.somethingWentWrong }),
1166
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("button", { type: "button", className: "ss-w-retry", onClick: () => controller.retry(), children: ui.retry })
980
1167
  ] }) : null,
981
- empty ? /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(IntentChips, { chips: state.starters, onPick: (t) => send(t) }) : null,
982
- /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("div", { ref: endRef })
1168
+ empty ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(IntentChips, { chips: state.starters, onPick: (t) => send(t) }) : null,
1169
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { ref: endRef })
983
1170
  ]
984
1171
  }
985
1172
  );
@@ -987,7 +1174,7 @@ function MessageList({ controller, ui, slip }) {
987
1174
 
988
1175
  // src/components/ChatInput.tsx
989
1176
  var import_react8 = require("react");
990
- var import_jsx_runtime16 = require("react/jsx-runtime");
1177
+ var import_jsx_runtime17 = require("react/jsx-runtime");
991
1178
  function ChatInput({ placeholder, streaming, onSend, onStop, stopLabel = "Stop response", sendLabel = "Send message" }) {
992
1179
  const [value, setValue] = (0, import_react8.useState)("");
993
1180
  const submit = () => {
@@ -1002,14 +1189,14 @@ function ChatInput({ placeholder, streaming, onSend, onStop, stopLabel = "Stop r
1002
1189
  submit();
1003
1190
  }
1004
1191
  };
1005
- return /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { className: "ss-w-input", children: [
1006
- /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("textarea", { className: "ss-w-textarea", rows: 1, placeholder, value, disabled: streaming, onChange: (e) => setValue(e.target.value), onKeyDown }),
1007
- streaming ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("button", { type: "button", className: "ss-w-send ss-w-stop", "aria-label": stopLabel, onClick: onStop, children: /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { className: "ss-w-stop-glyph" }) }) : /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("button", { type: "button", className: "ss-w-send", "aria-label": sendLabel, onClick: submit, children: /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(SendIcon, {}) })
1192
+ return /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { className: "ss-w-input", children: [
1193
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("textarea", { className: "ss-w-textarea", rows: 1, placeholder, value, disabled: streaming, onChange: (e) => setValue(e.target.value), onKeyDown }),
1194
+ streaming ? /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("button", { type: "button", className: "ss-w-send ss-w-stop", "aria-label": stopLabel, onClick: onStop, children: /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("span", { className: "ss-w-stop-glyph" }) }) : /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("button", { type: "button", className: "ss-w-send", "aria-label": sendLabel, onClick: submit, children: /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(SendIcon, {}) })
1008
1195
  ] });
1009
1196
  }
1010
1197
 
1011
1198
  // src/components/ChatPanel.tsx
1012
- var import_jsx_runtime17 = require("react/jsx-runtime");
1199
+ var import_jsx_runtime18 = require("react/jsx-runtime");
1013
1200
  var FULLSCREEN_QUERY = "(max-width: 639px)";
1014
1201
  function useIsModal() {
1015
1202
  const [modal, setModal] = (0, import_react9.useState)(false);
@@ -1033,10 +1220,10 @@ function ChatPanel({ controller, ui, slip }) {
1033
1220
  window.addEventListener("keydown", onKey);
1034
1221
  return () => window.removeEventListener("keydown", onKey);
1035
1222
  }, [close]);
1036
- return /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { className: "ss-w-panel", role: "dialog", "aria-modal": isModal, "aria-label": ui.chatDialogLabel, children: [
1037
- /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(ChatHeader, { onClose: close, closeLabel: ui.closeChat }),
1038
- /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(MessageList, { controller, ui, slip }),
1039
- /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(ChatInput, { placeholder: ui.placeholder, streaming: state.isStreaming, onSend: (t) => send(t), onStop: stop, stopLabel: ui.stopResponse, sendLabel: ui.sendMessage })
1223
+ return /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("div", { className: "ss-w-panel", role: "dialog", "aria-modal": isModal, "aria-label": ui.chatDialogLabel, children: [
1224
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(ChatHeader, { onClose: close, closeLabel: ui.closeChat }),
1225
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(MessageList, { controller, ui, slip }),
1226
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(ChatInput, { placeholder: ui.placeholder, streaming: state.isStreaming, onSend: (t) => send(t), onStop: stop, stopLabel: ui.stopResponse, sendLabel: ui.sendMessage })
1040
1227
  ] });
1041
1228
  }
1042
1229
 
@@ -1235,6 +1422,30 @@ var WIDGET_CSS = `.ss-w-root { all: revert; }
1235
1422
  .ss-w-mo-row .ss-w-slip { margin: 0; }
1236
1423
  .ss-w-mo-foot { display: flex; align-items: center; gap: 8px; font-size: 10.5px; color: var(--ss-w-faint); }
1237
1424
 
1425
+ /* odds movement card */
1426
+ .ss-w-omcards { display: flex; flex-direction: column; gap: 7px; }
1427
+ .ss-w-omcard { background: var(--ss-w-panel); border: 1px solid var(--ss-w-line); border-radius: 12px; border-left: 3px solid var(--ss-w-accent); padding: 9px 11px; display: flex; flex-direction: column; gap: 6px; }
1428
+ .ss-w-om-head { display: flex; align-items: center; gap: 8px; }
1429
+ .ss-w-om-market { font-size: 13px; font-weight: 700; color: var(--ss-w-ink); }
1430
+ .ss-w-om-tag { margin-left: auto; flex: none; font-size: 9px; font-weight: 700; letter-spacing: .09em; text-transform: uppercase; color: var(--ss-w-faint); border: 1px solid var(--ss-w-line); border-radius: 8px; padding: 2px 7px; }
1431
+ .ss-w-om-fixture { display: flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--ss-w-faint); min-width: 0; }
1432
+ .ss-w-om-comp { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1433
+ .ss-w-om-sel { font-size: 12.5px; font-weight: 600; color: var(--ss-w-ink); }
1434
+ .ss-w-om-chart { display: block; width: 100%; height: auto; }
1435
+ /* Single neutral accent in BOTH directions, deliberately. Colouring a shortening price green
1436
+ frames it as a discount, which inverts what it means for the bettor -- the same reason
1437
+ .ss-w-move keeps both directions on --ss-w-faint. */
1438
+ .ss-w-om-line { stroke: var(--ss-w-accent); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
1439
+ .ss-w-om-dot { fill: var(--ss-w-accent); }
1440
+ .ss-w-om-grid { stroke: var(--ss-w-line); stroke-width: 1; }
1441
+ .ss-w-om-ylab, .ss-w-om-xlab { fill: var(--ss-w-faint); font-size: 9px; font-family: ui-monospace, monospace; }
1442
+ .ss-w-om-empty { font-size: 11.5px; color: var(--ss-w-faint); padding: 6px 0; }
1443
+ .ss-w-om-foot { display: flex; align-items: center; gap: 14px; font-size: 11.5px; color: var(--ss-w-faint); }
1444
+ .ss-w-om-foot b { font-family: ui-monospace, monospace; font-variant-numeric: tabular-nums; font-size: 13px; color: var(--ss-w-ink); }
1445
+ .ss-w-om-excl { font-size: 10.5px; color: var(--ss-w-faint); }
1446
+ .ss-w-om-susp { font-size: 11px; font-weight: 600; color: #c0392b; }
1447
+ .ss-w-om-disc { font-size: 10px; font-style: italic; color: var(--ss-w-faint); }
1448
+
1238
1449
  /* action buttons */
1239
1450
  .ss-w-actions { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; }
1240
1451
  .ss-w-abtn { font-size: 12px; font-weight: 560; color: var(--ss-w-accent); background: var(--ss-w-panel); border: 1px solid var(--ss-w-accent); border-radius: 16px; padding: 6px 12px; cursor: pointer; }
@@ -1272,7 +1483,7 @@ var WIDGET_CSS = `.ss-w-root { all: revert; }
1272
1483
  `;
1273
1484
 
1274
1485
  // src/StatsWidget.tsx
1275
- var import_jsx_runtime18 = require("react/jsx-runtime");
1486
+ var import_jsx_runtime19 = require("react/jsx-runtime");
1276
1487
  function buildClient(props) {
1277
1488
  try {
1278
1489
  if ("client" in props && props.client) {
@@ -1321,7 +1532,7 @@ function StatsWidget(props) {
1321
1532
  }, [built, ui.injectStyles]);
1322
1533
  if (!built) return null;
1323
1534
  const attrs = themeAttrs(ui);
1324
- return /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("div", { ref: rootRef, className: "ss-w-root", "data-ss-w-theme": attrs["data-ss-w-theme"], "data-ss-w-pos": attrs["data-ss-w-pos"], style: attrs.style, children: controller.state.isOpen ? /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(ChatPanel, { controller, ui, slip }) : /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
1535
+ return /* @__PURE__ */ (0, import_jsx_runtime19.jsx)("div", { ref: rootRef, className: "ss-w-root", "data-ss-w-theme": attrs["data-ss-w-theme"], "data-ss-w-pos": attrs["data-ss-w-pos"], style: attrs.style, children: controller.state.isOpen ? /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(ChatPanel, { controller, ui, slip }) : /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
1325
1536
  ChatFab,
1326
1537
  {
1327
1538
  label: ui.launcherLabel,