@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/dist/index.js CHANGED
@@ -28,6 +28,20 @@ var STRINGS = {
28
28
  oddsShortened: "shortened",
29
29
  oddsDrifted: "drifted",
30
30
  updatedLabel: "updated",
31
+ priceMovement: "Price movement",
32
+ openingLabel: "Opening",
33
+ currentLabel: "Current",
34
+ movementWindow: (hours) => hours % 24 === 0 ? `Last ${hours / 24}d` : `Last ${hours}h`,
35
+ notEnoughHistory: "No price history recorded yet for this selection.",
36
+ singlePricePoint: "Only one price recorded so far for this selection.",
37
+ selectionSuspended: "This selection is not currently available to bet.",
38
+ excludedSuspended: (n) => `${n} suspended price${n === 1 ? "" : "s"} excluded from this chart.`,
39
+ chartTimeLabel: (ms) => new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }).format(new Date(ms)),
40
+ chartAria: (opening, current, direction, hours) => {
41
+ const span = hours % 24 === 0 ? `${hours / 24} day${hours / 24 === 1 ? "" : "s"}` : `${hours} hour${hours === 1 ? "" : "s"}`;
42
+ const drift = direction ? `, ${direction}` : "";
43
+ return `Price movement over the last ${span}: opened at ${opening}, currently ${current}${drift}.`;
44
+ },
31
45
  inSlip: "In slip",
32
46
  slipAdded: (n) => n === void 0 ? "Added to slip" : `Added to slip (${n} selection${n === 1 ? "" : "s"})`,
33
47
  slipDuplicate: "Already in your slip",
@@ -61,6 +75,20 @@ var STRINGS = {
61
75
  oddsShortened: "d\xFC\u015Ft\xFC",
62
76
  oddsDrifted: "y\xFCkseldi",
63
77
  updatedLabel: "g\xFCncellendi",
78
+ priceMovement: "Oran hareketi",
79
+ openingLabel: "A\xE7\u0131l\u0131\u015F",
80
+ currentLabel: "G\xFCncel",
81
+ movementWindow: (hours) => hours % 24 === 0 ? `Son ${hours / 24} g\xFCn` : `Son ${hours} saat`,
82
+ notEnoughHistory: "Bu se\xE7im i\xE7in hen\xFCz oran ge\xE7mi\u015Fi kaydedilmedi.",
83
+ singlePricePoint: "Bu se\xE7im i\xE7in \u015Fimdiye kadar yaln\u0131zca bir fiyat kaydedildi.",
84
+ selectionSuspended: "Bu se\xE7im \u015Fu anda bahse kapal\u0131.",
85
+ excludedSuspended: (n) => `${n} ask\u0131ya al\u0131nm\u0131\u015F fiyat bu grafikten hari\xE7 tutuldu.`,
86
+ chartTimeLabel: (ms) => new Intl.DateTimeFormat("tr-TR", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }).format(new Date(ms)),
87
+ chartAria: (opening, current, direction, hours) => {
88
+ const span = hours % 24 === 0 ? `${hours / 24} g\xFCn` : `${hours} saat`;
89
+ const drift = direction ? `, ${direction}` : "";
90
+ return `Son ${span} i\xE7indeki oran hareketi: ${opening} ile a\xE7\u0131ld\u0131, \u015Fu anda ${current}${drift}.`;
91
+ },
64
92
  inSlip: "Kuponda",
65
93
  slipAdded: (n) => n === void 0 ? "Kupona eklendi" : `Kupona eklendi (${n} se\xE7im)`,
66
94
  slipDuplicate: "Bu se\xE7im kuponunuzda zaten var",
@@ -110,6 +138,16 @@ function normalizeUi(cfg) {
110
138
  oddsShortened: strings.oddsShortened,
111
139
  oddsDrifted: strings.oddsDrifted,
112
140
  updatedLabel: strings.updatedLabel,
141
+ priceMovement: strings.priceMovement,
142
+ openingLabel: strings.openingLabel,
143
+ currentLabel: strings.currentLabel,
144
+ movementWindow: strings.movementWindow,
145
+ notEnoughHistory: strings.notEnoughHistory,
146
+ singlePricePoint: strings.singlePricePoint,
147
+ selectionSuspended: strings.selectionSuspended,
148
+ excludedSuspended: strings.excludedSuspended,
149
+ chartTimeLabel: strings.chartTimeLabel,
150
+ chartAria: strings.chartAria,
113
151
  addToSlip: strings.addToSlip,
114
152
  inSlip: strings.inSlip,
115
153
  slipAdded: strings.slipAdded,
@@ -148,6 +186,54 @@ import { WidgetSdkError as WidgetSdkError2 } from "@sensiblestats/widget-sdk";
148
186
  // src/reducer.ts
149
187
  import "@sensiblestats/widget-sdk";
150
188
 
189
+ // src/chart.ts
190
+ var CHART_VIEW_W = 320;
191
+ var CHART_VIEW_H = 140;
192
+ var PLOT_X0 = 40;
193
+ var PLOT_X1 = 312;
194
+ var PLOT_Y0 = 10;
195
+ var PLOT_Y1 = 110;
196
+ var MARKER_MAX_POINTS = 8;
197
+ var FLAT_DOMAIN_PAD = 0.05;
198
+ var round2 = (n) => Math.round(n * 100) / 100;
199
+ function projectSeries(samples) {
200
+ if (samples.length < 2) return null;
201
+ const tMin = samples[0].atMs;
202
+ const tMax = samples[samples.length - 1].atMs;
203
+ if (tMax <= tMin) return null;
204
+ const prices = samples.map((s) => s.price);
205
+ const pMin = Math.min(...prices);
206
+ const pMax = Math.max(...prices);
207
+ let yMin;
208
+ let yMax;
209
+ if (pMax === pMin) {
210
+ yMin = pMin - FLAT_DOMAIN_PAD;
211
+ yMax = pMax + FLAT_DOMAIN_PAD;
212
+ } else {
213
+ const pad = (pMax - pMin) * 0.1;
214
+ yMin = pMin - pad;
215
+ yMax = pMax + pad;
216
+ }
217
+ const x = (atMs) => round2(PLOT_X0 + (atMs - tMin) / (tMax - tMin) * (PLOT_X1 - PLOT_X0));
218
+ const y = (price) => round2(PLOT_Y1 - (price - yMin) / (yMax - yMin) * (PLOT_Y1 - PLOT_Y0));
219
+ const projected = samples.map((s) => ({ x: x(s.atMs), y: y(s.price) }));
220
+ const path = projected.map((p, i) => `${i === 0 ? "M" : "L"}${p.x} ${p.y}`).join(" ");
221
+ return {
222
+ path,
223
+ markers: samples.length <= MARKER_MAX_POINTS ? projected : [],
224
+ // The real extremes, never the padded bounds: a gridline label is a price, and the padded
225
+ // bounds are prices nobody was ever offered. A flat series has one real extreme, not two —
226
+ // two identical gridlines would collide on both React key and rendered text.
227
+ gridlines: pMax === pMin ? [{ y: y(pMax), price: pMax }] : [
228
+ { y: y(pMax), price: pMax },
229
+ { y: y(pMin), price: pMin }
230
+ ],
231
+ viewBox: `0 0 ${CHART_VIEW_W} ${CHART_VIEW_H}`,
232
+ startMs: tMin,
233
+ endMs: tMax
234
+ };
235
+ }
236
+
151
237
  // src/model.ts
152
238
  function str(v) {
153
239
  return typeof v === "string" && v.length > 0 ? v : void 0;
@@ -290,6 +376,41 @@ function toMarketOddsVM(card) {
290
376
  addToSlipEnabled: card.addToSlipEnabled === true
291
377
  };
292
378
  }
379
+ function toOddsMovementVM(card) {
380
+ const raw = Array.isArray(card.points) ? card.points : [];
381
+ const samples = [];
382
+ for (const p of raw) {
383
+ const price = typeof p.price === "number" && Number.isFinite(p.price) ? p.price : void 0;
384
+ const atMs = typeof p.atUtc === "string" ? Date.parse(p.atUtc) : NaN;
385
+ if (price === void 0 || Number.isNaN(atMs)) continue;
386
+ samples.push({ atMs, price });
387
+ }
388
+ samples.sort((a, b) => a.atMs - b.atMs);
389
+ const asNumber = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
390
+ const opening = asNumber(card.openingPrice);
391
+ const current = asNumber(card.currentPrice);
392
+ const home = str(card.homeTeamName);
393
+ const away = str(card.awayTeamName);
394
+ const selection = [str(card.selectionLabel), str(card.line)].filter(Boolean).join(" ");
395
+ const bookmakerId = typeof card.bookmakerId === "number" ? card.bookmakerId : "";
396
+ return {
397
+ id: `${typeof card.matchId === "number" ? card.matchId : 0}:${str(card.marketDeveloperName) ?? ""}:${selection}:${bookmakerId}`,
398
+ market: str(card.marketLabel) ?? str(card.marketDeveloperName) ?? "",
399
+ selection,
400
+ fixture: home && away ? `${home} vs ${away}` : void 0,
401
+ competition: str(card.competitionName),
402
+ bookmaker: str(card.bookmakerName),
403
+ opening: opening !== void 0 ? opening.toFixed(2) : "",
404
+ current: current !== void 0 ? current.toFixed(2) : "",
405
+ direction: card.movementDirection === "shortened" || card.movementDirection === "drifted" ? card.movementDirection : void 0,
406
+ windowHours: asNumber(card.windowHours) ?? 0,
407
+ excludedCount: asNumber(card.excludedSuspendedCount) ?? 0,
408
+ isSuspended: card.isSuspended === true,
409
+ disclaimer: str(card.disclaimer),
410
+ geometry: projectSeries(samples),
411
+ observationCount: samples.length
412
+ };
413
+ }
293
414
  function marketOddsRowToSlipSelection(vm, row, conversationId) {
294
415
  if (row.matchOddsId === void 0 || row.oddsDecimal === void 0) return null;
295
416
  const selection = { matchOddsId: row.matchOddsId, matchId: vm.matchId, oddsDecimal: row.oddsDecimal };
@@ -330,6 +451,8 @@ function applyEvent(state, event) {
330
451
  return { ...state, messages: mapActive(state, (t) => ({ ...t, insights: [...t.insights, toInsightVM(event.data.insight)] })) };
331
452
  case "market_odds":
332
453
  return { ...state, messages: mapActive(state, (t) => ({ ...t, marketOdds: [...t.marketOdds, toMarketOddsVM(event.data.card)] })) };
454
+ case "odds_movement":
455
+ return { ...state, messages: mapActive(state, (t) => ({ ...t, oddsMovement: [...t.oddsMovement, toOddsMovementVM(event.data.card)] })) };
333
456
  case "intent_chip": {
334
457
  const chip = toChipVM(event.data.intent);
335
458
  return chip ? { ...state, starters: [...state.starters, chip] } : state;
@@ -365,11 +488,11 @@ function reducer(state, action) {
365
488
  return { ...state, isOpen: false, isStreaming: false, status: null, actionsLoading: false };
366
489
  case "USER_SENT": {
367
490
  const user = { role: "user", id: action.userId, text: action.displayText ?? action.input.message };
368
- const assistant = { role: "assistant", id: action.assistantId, text: "", cards: [], actions: [], insights: [], marketOdds: [], done: false };
491
+ const assistant = { role: "assistant", id: action.assistantId, text: "", cards: [], actions: [], insights: [], marketOdds: [], oddsMovement: [], done: false };
369
492
  return { ...state, messages: [...state.messages, user, assistant], isStreaming: true, status: null, actionsLoading: false, starters: [], error: null, lastUserInput: action.input, lastDisplayText: action.displayText ?? null };
370
493
  }
371
494
  case "GREETING_SENT": {
372
- const assistant = { role: "assistant", id: action.assistantId, text: "", cards: [], actions: [], insights: [], marketOdds: [], done: false, isGreeting: true };
495
+ const assistant = { role: "assistant", id: action.assistantId, text: "", cards: [], actions: [], insights: [], marketOdds: [], oddsMovement: [], done: false, isGreeting: true };
373
496
  return { ...state, messages: [...state.messages, assistant], isStreaming: true, status: null, error: null };
374
497
  }
375
498
  case "DISMISS_POPUP":
@@ -735,8 +858,8 @@ function OddsMovementBadge({
735
858
  ui
736
859
  }) {
737
860
  if (previous === void 0 || direction === void 0) return null;
738
- const directionWord = direction === "shortened" ? ui?.oddsShortened ?? "shortened" : ui?.oddsDrifted ?? "drifted";
739
- const label = ui?.oddsMoved?.(previous, current, directionWord) ?? `was ${previous}, now ${current}, ${directionWord}`;
861
+ const directionWord2 = direction === "shortened" ? ui?.oddsShortened ?? "shortened" : ui?.oddsDrifted ?? "drifted";
862
+ const label = ui?.oddsMoved?.(previous, current, directionWord2) ?? `was ${previous}, now ${current}, ${directionWord2}`;
740
863
  return (
741
864
  // Strikethrough is invisible to screen readers, so the whole was/now/direction sentence rides
742
865
  // on one aria-label and the visual glyphs are hidden from the accessibility tree.
@@ -861,39 +984,107 @@ function MarketOddsCardList({ cards, ui, slip }) {
861
984
  return /* @__PURE__ */ jsx11("div", { className: "ss-w-mocards", children: cards.map((vm) => /* @__PURE__ */ jsx11(MarketOddsCard, { vm, ui, slip }, vm.id)) });
862
985
  }
863
986
 
864
- // src/components/ActionButtons.tsx
987
+ // src/components/OddsMovementCard.tsx
865
988
  import { Fragment, jsx as jsx12, jsxs as jsxs9 } from "react/jsx-runtime";
989
+ function directionWord(vm, ui) {
990
+ if (vm.direction === "shortened") return ui?.oddsShortened ?? "shortened";
991
+ if (vm.direction === "drifted") return ui?.oddsDrifted ?? "drifted";
992
+ return "";
993
+ }
994
+ function OddsMovementCard({ vm, ui }) {
995
+ const word = directionWord(vm, ui);
996
+ const drift = word ? `, ${word}` : "";
997
+ 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}.`;
998
+ return /* @__PURE__ */ jsxs9("div", { className: "ss-w-omcard", children: [
999
+ /* @__PURE__ */ jsxs9("div", { className: "ss-w-om-head", children: [
1000
+ /* @__PURE__ */ jsx12("span", { className: "ss-w-om-market", children: vm.market }),
1001
+ /* @__PURE__ */ jsx12("span", { className: "ss-w-om-tag", children: ui?.priceMovement ?? "Price movement" })
1002
+ ] }),
1003
+ vm.fixture ? /* @__PURE__ */ jsxs9("div", { className: "ss-w-om-fixture", children: [
1004
+ /* @__PURE__ */ jsx12("span", { children: vm.fixture }),
1005
+ vm.competition ? /* @__PURE__ */ jsx12("span", { className: "ss-w-om-comp", children: vm.competition }) : null
1006
+ ] }) : null,
1007
+ /* @__PURE__ */ jsx12("div", { className: "ss-w-om-sel", children: vm.selection }),
1008
+ /* @__PURE__ */ jsx12(OddsMovementBadge, { previous: vm.opening, current: vm.current, direction: vm.direction, ui }),
1009
+ vm.geometry === null ? /* @__PURE__ */ jsx12("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__ */ jsxs9(
1010
+ "svg",
1011
+ {
1012
+ className: "ss-w-om-chart",
1013
+ viewBox: vm.geometry.viewBox,
1014
+ width: "100%",
1015
+ role: "img",
1016
+ "aria-label": aria,
1017
+ children: [
1018
+ vm.geometry.gridlines.map((g) => /* @__PURE__ */ jsxs9("g", { children: [
1019
+ /* @__PURE__ */ jsx12("line", { className: "ss-w-om-grid", x1: 40, y1: g.y, x2: CHART_VIEW_W - 8, y2: g.y }),
1020
+ /* @__PURE__ */ jsx12("text", { className: "ss-w-om-ylab", x: 34, y: g.y + 3, textAnchor: "end", children: g.price.toFixed(2) })
1021
+ ] }, g.price)),
1022
+ /* @__PURE__ */ jsx12("path", { className: "ss-w-om-line", d: vm.geometry.path, fill: "none" }),
1023
+ vm.geometry.markers.map((m, i) => /* @__PURE__ */ jsx12("circle", { className: "ss-w-om-dot", cx: m.x, cy: m.y, r: 2.5 }, i)),
1024
+ ui?.chartTimeLabel ? /* @__PURE__ */ jsxs9(Fragment, { children: [
1025
+ /* @__PURE__ */ jsx12("text", { className: "ss-w-om-xlab", x: 40, y: CHART_VIEW_H - 8, textAnchor: "start", children: ui.chartTimeLabel(vm.geometry.startMs) }),
1026
+ /* @__PURE__ */ jsx12("text", { className: "ss-w-om-xlab", x: CHART_VIEW_W - 8, y: CHART_VIEW_H - 8, textAnchor: "end", children: ui.chartTimeLabel(vm.geometry.endMs) })
1027
+ ] }) : /* @__PURE__ */ jsx12("text", { className: "ss-w-om-xlab", x: 40, y: CHART_VIEW_H - 8, children: ui?.movementWindow ? ui.movementWindow(vm.windowHours) : `Last ${vm.windowHours}h` })
1028
+ ]
1029
+ }
1030
+ ),
1031
+ /* @__PURE__ */ jsxs9("div", { className: "ss-w-om-foot", children: [
1032
+ /* @__PURE__ */ jsxs9("span", { children: [
1033
+ ui?.openingLabel ?? "Opening",
1034
+ " ",
1035
+ /* @__PURE__ */ jsx12("b", { children: vm.opening })
1036
+ ] }),
1037
+ /* @__PURE__ */ jsxs9("span", { children: [
1038
+ ui?.currentLabel ?? "Current",
1039
+ " ",
1040
+ /* @__PURE__ */ jsx12("b", { children: vm.current })
1041
+ ] })
1042
+ ] }),
1043
+ vm.bookmaker ? /* @__PURE__ */ jsx12("div", { className: "ss-w-om-foot", children: /* @__PURE__ */ jsx12("span", { children: vm.bookmaker }) }) : null,
1044
+ vm.excludedCount > 0 ? /* @__PURE__ */ jsx12("div", { className: "ss-w-om-excl", children: ui?.excludedSuspended ? ui.excludedSuspended(vm.excludedCount) : `${vm.excludedCount} suspended price(s) excluded from this chart.` }) : null,
1045
+ vm.isSuspended ? /* @__PURE__ */ jsx12("div", { className: "ss-w-om-susp", children: ui?.selectionSuspended ?? "This selection is not currently available to bet." }) : null,
1046
+ vm.disclaimer ? /* @__PURE__ */ jsx12("div", { className: "ss-w-om-disc", children: vm.disclaimer }) : null
1047
+ ] });
1048
+ }
1049
+ function OddsMovementCardList({ cards, ui }) {
1050
+ if (!cards || cards.length === 0) return null;
1051
+ return /* @__PURE__ */ jsx12("div", { className: "ss-w-omcards", children: cards.map((vm) => /* @__PURE__ */ jsx12(OddsMovementCard, { vm, ui }, vm.id)) });
1052
+ }
1053
+
1054
+ // src/components/ActionButtons.tsx
1055
+ import { Fragment as Fragment2, jsx as jsx13, jsxs as jsxs10 } from "react/jsx-runtime";
866
1056
  function ActionButtons({ actions, loading, disabled, onAct }) {
867
1057
  if (actions.length === 0 && !loading) return null;
868
- return /* @__PURE__ */ jsxs9("div", { className: "ss-w-actions", children: [
869
- actions.map((a, i) => /* @__PURE__ */ jsx12("button", { type: "button", className: "ss-w-abtn", disabled, onClick: () => onAct(a), children: a.label }, `${a.label}-${i}`)),
870
- actions.length === 0 && loading ? /* @__PURE__ */ jsxs9(Fragment, { children: [
871
- /* @__PURE__ */ jsx12("span", { className: "ss-w-abtn-shimmer" }),
872
- /* @__PURE__ */ jsx12("span", { className: "ss-w-abtn-shimmer" })
1058
+ return /* @__PURE__ */ jsxs10("div", { className: "ss-w-actions", children: [
1059
+ actions.map((a, i) => /* @__PURE__ */ jsx13("button", { type: "button", className: "ss-w-abtn", disabled, onClick: () => onAct(a), children: a.label }, `${a.label}-${i}`)),
1060
+ actions.length === 0 && loading ? /* @__PURE__ */ jsxs10(Fragment2, { children: [
1061
+ /* @__PURE__ */ jsx13("span", { className: "ss-w-abtn-shimmer" }),
1062
+ /* @__PURE__ */ jsx13("span", { className: "ss-w-abtn-shimmer" })
873
1063
  ] }) : null
874
1064
  ] });
875
1065
  }
876
1066
 
877
1067
  // src/components/ChatMessage.tsx
878
- import { jsx as jsx13, jsxs as jsxs10 } from "react/jsx-runtime";
1068
+ import { jsx as jsx14, jsxs as jsxs11 } from "react/jsx-runtime";
879
1069
  function ChatMessage({ message, disabled, onAct, ui, slip }) {
880
1070
  if (message.role === "user") {
881
- return /* @__PURE__ */ jsx13("div", { className: "ss-w-msg ss-w-user", children: /* @__PURE__ */ jsx13("div", { className: "ss-w-bubble", children: message.text }) });
1071
+ return /* @__PURE__ */ jsx14("div", { className: "ss-w-msg ss-w-user", children: /* @__PURE__ */ jsx14("div", { className: "ss-w-bubble", children: message.text }) });
882
1072
  }
883
- return /* @__PURE__ */ jsxs10("div", { className: "ss-w-msg ss-w-bot", children: [
884
- message.text ? /* @__PURE__ */ jsx13(Markdown, { text: message.text }) : null,
885
- /* @__PURE__ */ jsx13(EntityCardList, { cards: message.cards, disabled, showLessLabel: ui?.showLess, showMoreLabel: ui?.showMore }),
886
- /* @__PURE__ */ jsx13(BettingInsightList, { insights: message.insights, ui, slip }),
887
- /* @__PURE__ */ jsx13(MarketOddsCardList, { cards: message.marketOdds, ui, slip }),
888
- /* @__PURE__ */ jsx13(ActionButtons, { actions: message.actions, loading: false, disabled, onAct })
1073
+ return /* @__PURE__ */ jsxs11("div", { className: "ss-w-msg ss-w-bot", children: [
1074
+ message.text ? /* @__PURE__ */ jsx14(Markdown, { text: message.text }) : null,
1075
+ /* @__PURE__ */ jsx14(EntityCardList, { cards: message.cards, disabled, showLessLabel: ui?.showLess, showMoreLabel: ui?.showMore }),
1076
+ /* @__PURE__ */ jsx14(BettingInsightList, { insights: message.insights, ui, slip }),
1077
+ /* @__PURE__ */ jsx14(MarketOddsCardList, { cards: message.marketOdds, ui, slip }),
1078
+ /* @__PURE__ */ jsx14(OddsMovementCardList, { cards: message.oddsMovement, ui }),
1079
+ /* @__PURE__ */ jsx14(ActionButtons, { actions: message.actions, loading: false, disabled, onAct })
889
1080
  ] });
890
1081
  }
891
1082
 
892
1083
  // src/components/IntentChips.tsx
893
- import { jsx as jsx14 } from "react/jsx-runtime";
1084
+ import { jsx as jsx15 } from "react/jsx-runtime";
894
1085
  function IntentChips({ chips, onPick }) {
895
1086
  if (chips.length === 0) return null;
896
- return /* @__PURE__ */ jsx14("div", { className: "ss-w-chips", children: chips.map((c, i) => /* @__PURE__ */ jsx14("button", { type: "button", className: "ss-w-chip", onClick: () => onPick(c.text), children: c.text }, `${c.text}-${i}`)) });
1087
+ return /* @__PURE__ */ jsx15("div", { className: "ss-w-chips", children: chips.map((c, i) => /* @__PURE__ */ jsx15("button", { type: "button", className: "ss-w-chip", onClick: () => onPick(c.text), children: c.text }, `${c.text}-${i}`)) });
897
1088
  }
898
1089
 
899
1090
  // src/scroll.ts
@@ -903,7 +1094,7 @@ function isPinnedToBottom(el, threshold = PIN_THRESHOLD) {
903
1094
  }
904
1095
 
905
1096
  // src/components/MessageList.tsx
906
- import { jsx as jsx15, jsxs as jsxs11 } from "react/jsx-runtime";
1097
+ import { jsx as jsx16, jsxs as jsxs12 } from "react/jsx-runtime";
907
1098
  function MessageList({ controller, ui, slip }) {
908
1099
  const { state, send } = controller;
909
1100
  const listRef = useRef2(null);
@@ -913,7 +1104,7 @@ function MessageList({ controller, ui, slip }) {
913
1104
  if (pinnedRef.current) endRef.current?.scrollIntoView({ block: "end" });
914
1105
  }, [state.messages, state.status]);
915
1106
  const empty = state.messages.length === 0;
916
- return /* @__PURE__ */ jsxs11(
1107
+ return /* @__PURE__ */ jsxs12(
917
1108
  "div",
918
1109
  {
919
1110
  className: "ss-w-list",
@@ -923,7 +1114,7 @@ function MessageList({ controller, ui, slip }) {
923
1114
  if (listRef.current) pinnedRef.current = isPinnedToBottom(listRef.current);
924
1115
  },
925
1116
  children: [
926
- state.messages.map((m) => /* @__PURE__ */ jsx15(
1117
+ state.messages.map((m) => /* @__PURE__ */ jsx16(
927
1118
  ChatMessage,
928
1119
  {
929
1120
  message: m,
@@ -934,15 +1125,15 @@ function MessageList({ controller, ui, slip }) {
934
1125
  },
935
1126
  m.id
936
1127
  )),
937
- state.isStreaming && state.actionsLoading ? /* @__PURE__ */ jsx15(ActionButtons, { actions: [], loading: true, onAct: () => {
1128
+ state.isStreaming && state.actionsLoading ? /* @__PURE__ */ jsx16(ActionButtons, { actions: [], loading: true, onAct: () => {
938
1129
  } }) : null,
939
- state.status ? /* @__PURE__ */ jsx15("div", { className: "ss-w-status", children: state.status }) : null,
940
- state.error ? /* @__PURE__ */ jsxs11("div", { className: "ss-w-error", role: "alert", children: [
941
- /* @__PURE__ */ jsx15("span", { children: "message" in state.error ? state.error.message : ui.somethingWentWrong }),
942
- /* @__PURE__ */ jsx15("button", { type: "button", className: "ss-w-retry", onClick: () => controller.retry(), children: ui.retry })
1130
+ state.status ? /* @__PURE__ */ jsx16("div", { className: "ss-w-status", children: state.status }) : null,
1131
+ state.error ? /* @__PURE__ */ jsxs12("div", { className: "ss-w-error", role: "alert", children: [
1132
+ /* @__PURE__ */ jsx16("span", { children: "message" in state.error ? state.error.message : ui.somethingWentWrong }),
1133
+ /* @__PURE__ */ jsx16("button", { type: "button", className: "ss-w-retry", onClick: () => controller.retry(), children: ui.retry })
943
1134
  ] }) : null,
944
- empty ? /* @__PURE__ */ jsx15(IntentChips, { chips: state.starters, onPick: (t) => send(t) }) : null,
945
- /* @__PURE__ */ jsx15("div", { ref: endRef })
1135
+ empty ? /* @__PURE__ */ jsx16(IntentChips, { chips: state.starters, onPick: (t) => send(t) }) : null,
1136
+ /* @__PURE__ */ jsx16("div", { ref: endRef })
946
1137
  ]
947
1138
  }
948
1139
  );
@@ -950,7 +1141,7 @@ function MessageList({ controller, ui, slip }) {
950
1141
 
951
1142
  // src/components/ChatInput.tsx
952
1143
  import { useState as useState5 } from "react";
953
- import { jsx as jsx16, jsxs as jsxs12 } from "react/jsx-runtime";
1144
+ import { jsx as jsx17, jsxs as jsxs13 } from "react/jsx-runtime";
954
1145
  function ChatInput({ placeholder, streaming, onSend, onStop, stopLabel = "Stop response", sendLabel = "Send message" }) {
955
1146
  const [value, setValue] = useState5("");
956
1147
  const submit = () => {
@@ -965,14 +1156,14 @@ function ChatInput({ placeholder, streaming, onSend, onStop, stopLabel = "Stop r
965
1156
  submit();
966
1157
  }
967
1158
  };
968
- return /* @__PURE__ */ jsxs12("div", { className: "ss-w-input", children: [
969
- /* @__PURE__ */ jsx16("textarea", { className: "ss-w-textarea", rows: 1, placeholder, value, disabled: streaming, onChange: (e) => setValue(e.target.value), onKeyDown }),
970
- streaming ? /* @__PURE__ */ jsx16("button", { type: "button", className: "ss-w-send ss-w-stop", "aria-label": stopLabel, onClick: onStop, children: /* @__PURE__ */ jsx16("span", { className: "ss-w-stop-glyph" }) }) : /* @__PURE__ */ jsx16("button", { type: "button", className: "ss-w-send", "aria-label": sendLabel, onClick: submit, children: /* @__PURE__ */ jsx16(SendIcon, {}) })
1159
+ return /* @__PURE__ */ jsxs13("div", { className: "ss-w-input", children: [
1160
+ /* @__PURE__ */ jsx17("textarea", { className: "ss-w-textarea", rows: 1, placeholder, value, disabled: streaming, onChange: (e) => setValue(e.target.value), onKeyDown }),
1161
+ streaming ? /* @__PURE__ */ jsx17("button", { type: "button", className: "ss-w-send ss-w-stop", "aria-label": stopLabel, onClick: onStop, children: /* @__PURE__ */ jsx17("span", { className: "ss-w-stop-glyph" }) }) : /* @__PURE__ */ jsx17("button", { type: "button", className: "ss-w-send", "aria-label": sendLabel, onClick: submit, children: /* @__PURE__ */ jsx17(SendIcon, {}) })
971
1162
  ] });
972
1163
  }
973
1164
 
974
1165
  // src/components/ChatPanel.tsx
975
- import { jsx as jsx17, jsxs as jsxs13 } from "react/jsx-runtime";
1166
+ import { jsx as jsx18, jsxs as jsxs14 } from "react/jsx-runtime";
976
1167
  var FULLSCREEN_QUERY = "(max-width: 639px)";
977
1168
  function useIsModal() {
978
1169
  const [modal, setModal] = useState6(false);
@@ -996,10 +1187,10 @@ function ChatPanel({ controller, ui, slip }) {
996
1187
  window.addEventListener("keydown", onKey);
997
1188
  return () => window.removeEventListener("keydown", onKey);
998
1189
  }, [close]);
999
- return /* @__PURE__ */ jsxs13("div", { className: "ss-w-panel", role: "dialog", "aria-modal": isModal, "aria-label": ui.chatDialogLabel, children: [
1000
- /* @__PURE__ */ jsx17(ChatHeader, { onClose: close, closeLabel: ui.closeChat }),
1001
- /* @__PURE__ */ jsx17(MessageList, { controller, ui, slip }),
1002
- /* @__PURE__ */ jsx17(ChatInput, { placeholder: ui.placeholder, streaming: state.isStreaming, onSend: (t) => send(t), onStop: stop, stopLabel: ui.stopResponse, sendLabel: ui.sendMessage })
1190
+ return /* @__PURE__ */ jsxs14("div", { className: "ss-w-panel", role: "dialog", "aria-modal": isModal, "aria-label": ui.chatDialogLabel, children: [
1191
+ /* @__PURE__ */ jsx18(ChatHeader, { onClose: close, closeLabel: ui.closeChat }),
1192
+ /* @__PURE__ */ jsx18(MessageList, { controller, ui, slip }),
1193
+ /* @__PURE__ */ jsx18(ChatInput, { placeholder: ui.placeholder, streaming: state.isStreaming, onSend: (t) => send(t), onStop: stop, stopLabel: ui.stopResponse, sendLabel: ui.sendMessage })
1003
1194
  ] });
1004
1195
  }
1005
1196
 
@@ -1198,6 +1389,30 @@ var WIDGET_CSS = `.ss-w-root { all: revert; }
1198
1389
  .ss-w-mo-row .ss-w-slip { margin: 0; }
1199
1390
  .ss-w-mo-foot { display: flex; align-items: center; gap: 8px; font-size: 10.5px; color: var(--ss-w-faint); }
1200
1391
 
1392
+ /* odds movement card */
1393
+ .ss-w-omcards { display: flex; flex-direction: column; gap: 7px; }
1394
+ .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; }
1395
+ .ss-w-om-head { display: flex; align-items: center; gap: 8px; }
1396
+ .ss-w-om-market { font-size: 13px; font-weight: 700; color: var(--ss-w-ink); }
1397
+ .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; }
1398
+ .ss-w-om-fixture { display: flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--ss-w-faint); min-width: 0; }
1399
+ .ss-w-om-comp { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1400
+ .ss-w-om-sel { font-size: 12.5px; font-weight: 600; color: var(--ss-w-ink); }
1401
+ .ss-w-om-chart { display: block; width: 100%; height: auto; }
1402
+ /* Single neutral accent in BOTH directions, deliberately. Colouring a shortening price green
1403
+ frames it as a discount, which inverts what it means for the bettor -- the same reason
1404
+ .ss-w-move keeps both directions on --ss-w-faint. */
1405
+ .ss-w-om-line { stroke: var(--ss-w-accent); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
1406
+ .ss-w-om-dot { fill: var(--ss-w-accent); }
1407
+ .ss-w-om-grid { stroke: var(--ss-w-line); stroke-width: 1; }
1408
+ .ss-w-om-ylab, .ss-w-om-xlab { fill: var(--ss-w-faint); font-size: 9px; font-family: ui-monospace, monospace; }
1409
+ .ss-w-om-empty { font-size: 11.5px; color: var(--ss-w-faint); padding: 6px 0; }
1410
+ .ss-w-om-foot { display: flex; align-items: center; gap: 14px; font-size: 11.5px; color: var(--ss-w-faint); }
1411
+ .ss-w-om-foot b { font-family: ui-monospace, monospace; font-variant-numeric: tabular-nums; font-size: 13px; color: var(--ss-w-ink); }
1412
+ .ss-w-om-excl { font-size: 10.5px; color: var(--ss-w-faint); }
1413
+ .ss-w-om-susp { font-size: 11px; font-weight: 600; color: #c0392b; }
1414
+ .ss-w-om-disc { font-size: 10px; font-style: italic; color: var(--ss-w-faint); }
1415
+
1201
1416
  /* action buttons */
1202
1417
  .ss-w-actions { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; }
1203
1418
  .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; }
@@ -1235,7 +1450,7 @@ var WIDGET_CSS = `.ss-w-root { all: revert; }
1235
1450
  `;
1236
1451
 
1237
1452
  // src/StatsWidget.tsx
1238
- import { jsx as jsx18 } from "react/jsx-runtime";
1453
+ import { jsx as jsx19 } from "react/jsx-runtime";
1239
1454
  function buildClient(props) {
1240
1455
  try {
1241
1456
  if ("client" in props && props.client) {
@@ -1284,7 +1499,7 @@ function StatsWidget(props) {
1284
1499
  }, [built, ui.injectStyles]);
1285
1500
  if (!built) return null;
1286
1501
  const attrs = themeAttrs(ui);
1287
- return /* @__PURE__ */ jsx18("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__ */ jsx18(ChatPanel, { controller, ui, slip }) : /* @__PURE__ */ jsx18(
1502
+ return /* @__PURE__ */ jsx19("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__ */ jsx19(ChatPanel, { controller, ui, slip }) : /* @__PURE__ */ jsx19(
1288
1503
  ChatFab,
1289
1504
  {
1290
1505
  label: ui.launcherLabel,