@sensiblestats/widget-react 0.12.0 → 0.13.1

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