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