Skip to content

<goma-pros-choice> ​

A single "The Pros' Choice" promo card: a host-fed artwork strip, the competition line (favourite star, sport glyph, region flag, name) over a fading hairline, the promoted market's name opposite the kick-off, the fixture as a small pill bled flush to the card's left edge, and a row of odds buttons labelled with outcome names. Player names for a scorer market, not Home / Draw / Away. An optional "See other markets" link closes the card.

One card per element: the host owns the rail. To render a row of picks, loop your own array and assign one object to each element's card, the same way <goma-suggested-bet> is used. The widget ships no carousel, no arrows, no dots and no section header.

Live demo ​

Open in the playground →: toggle Pros' choice in the widget rail. The bundled mock source renders a scorer-market card whose three prices drift every 3 s, so the odds-tick animation is visible; switch the inspector's source to prop and paste your own object into the JSON tab to try a CMS record.

Element tag ​

html
<goma-pros-choice></goma-pros-choice>

Installation ​

bash
pnpm add @gomagaming/pros-choice vue pinia vue-router vue-i18n @vueuse/core autobahn-browser

The framework packages are peer dependencies installed once at the app level; bundlers dedup them across every @gomagaming/* widget. This widget declares no swiper peer: it renders no carousel. autobahn-browser is declared, as it is by every package that depends on @gomagaming/sports-domain: the shared package's socket adapter chunk references it. This widget never loads that chunk (it opens no socket), but the specifier still has to resolve at bundle time. See Installation for the one-time .npmrc token setup.

Configuration ​

el.config = { locale, theme, messages, debug }: the standard widget config bag. In the default dataSource: 'prop' mode the card is fully host-fed and makes no network calls. The auto modes ('rpc' / 'subscribe') additionally need socketUrl: see below.

KeyTypeDescription
localestringActive i18n locale ('en' / 'fr' / 'pt'). Defaults to 'en'.
favoritesApiBaseUrlstringFavourites REST base URL. When set, star taps on this widget are saved to the player's favourites, also on a page with no <goma-favorites>, when a signed-in session is available on the page. The operator is ucsOperatorId.
themeRecord<string,string>--goma-* token overrides.
messagesobjectOptional i18n phrase overrides.
debugbooleanDefaults to false.
socketUrlstringAuto mode only. WAMP endpoint. Required by dataSource: 'rpc' | 'subscribe'; without it the widget warns once and degrades to the host-fed card.
socketRealmstringAuto mode only. WAMP realm.
ucsOperatorIdstringAuto mode only. Operator id used by the feed.

Data sources ​

The card property (a single object, not an array) is always the entry point. It accepts either a live object (JS property) or a JSON string (HTML attribute / the playground's JSON control); a malformed string, an array, or an object with nothing renderable in it renders nothing rather than a broken card.

What fills the card in depends on dataSource:

  • 'prop' (default): fully host-fed. Everything. Artwork, copy, market name, outcome labels and prices. Arrives from the host, and no socket is opened.
  • 'subscribe': auto, live. Feed { eventId, marketId / defaultMarket, image } (the event is looked up by id, so no sportId is needed to resolve it) and the widget resolves competition, flag, fixture, kick-off, market name, outcome labels and ticking odds from the live feed.
  • 'rpc': auto, one-shot. Same resolution, but the topics are released once a complete priced snapshot has landed.

Switching a mounted card's dataSource to 'prop', or clearing its card, releases the topics it had open rather than holding them until the element leaves the page, which matters because every widget on the page shares one socket. Switching back to a live mode reopens them.

Both auto modes need config.socketUrl; without one the widget warns once and degrades to the host-fed card. Because this card's premise is a non-main market (an anytime-scorer line, say), matchOdds defaults to 'auto' here: the by-id events feed only streams ~5 main markets per event, so the per-match {eventId}/match-odds feed is the only route to the market the card names.

Precedence in the auto modes: the host wins for anything a human wrote (team names, competition, market name, kick-off), the feed wins for identity and price (marketId, bettingTypeId, eventPartId, eventId, isLive and every outcomes[] field), and presentation (image, cta, flags) is host-only and never touched. Until the event lands the card shows a skeleton, or the host's own display fields, if it supplied any, and a 15 s watchdog stops it waiting forever.

js
document.querySelector('goma-pros-choice').card = {
  id: 'pick-scorer-bar-ray',
  image: 'https://cdn.example.com/promo/scorer.jpg',
  imageAlt: 'Anytime scorer promo',

  eventId: '2214800000000123456',
  sportId: 1,
  competitionId: '9100000000000000007',
  competitionName: 'La Liga',
  venueId: 26,

  marketName: 'Goaler',
  marketId: '9900000000000000042',
  bettingTypeId: '128',
  eventPartId: '1',

  homeTeam: 'FC Barcelona',
  awayTeam: 'Rayo Vallecano',
  startTime: 1791225000000,

  outcomes: [
    { outcomeId: 'o1', bettingOfferId: 'bo1', label: 'Bellingham, Jude', odds: 1.97 },
    { outcomeId: 'o2', bettingOfferId: 'bo2', label: 'Mbappé, Kylian',   odds: 2.15 },
    { outcomeId: 'o3', bettingOfferId: 'bo3', label: 'Júnior, Vinícius', odds: 2.40 },
  ],

  cta: { label: 'See other markets', url: '/event/2214800000000123456' },
}

Choosing the market (auto modes) ​

The host is responsible for the market config. defaultMarket (or marketId) must name a market that the card's event carries. The widget does not pick another market for you (unless you set marketFallback: 'first').

This card promotes a non-main market (a scorer market, say), so with matchOdds: 'auto' (the default) it opens a per-match odds feed for it. How you name the market decides how much that feed downloads:

defaultMarket formExampleWhat the card downloads
{bettingTypeId}_{eventPartId} (recommended)'30_3'Only that market's feed, {eventId}/match-odds/30-3 (tens of KB)
bettingTypeId only'30'Every market of the match (several MB per match on a real operator), because the period is unknown

The card shows the first three priced outcomes of the market, in the order the feed sends them. Outcomes the provider lists but never prices (a scorer market's "None", say) are left out.

When the market is not found, the card renders with no odds row. Once it has stayed that way for 5 s, the widget logs one console warning per event and market, listing the markets the card can see for that event:

[goma-pros-choice] market "30" is not on event 317502630930751488, so the card shows no odds. Markets this card can see for it: 69_3 (Home Draw Away), 47_3 (Over/Under), 76_3 (Both Teams To Score), ...

With matchOdds on, this means the event does not offer the market, or offers it under another period. Fix the card's defaultMarket (pick one the warning lists). Prefer that over marketFallback: 'first', which shows a market the CMS did not choose.

One record shape, two widgets ​

<goma-pros-choice> and <goma-highlight-card> take the same host-fed record: the GomaPromoCard below. The two ship together, compose the same shared card pieces and are meant to be authored from one CMS record type, so a client builds one mapping table, not two. Feed the same object to either element and it renders; feed it to both and they produce identical betslip legs.

Two caveats, and only two:

  • homeTeamLogo / awayTeamLogo are highlight-card-only. That card draws 19×19 team crests beside its two team rows; this one draws a fixture pill and ignores both fields. They are the only fields in the record this widget does not read.
  • fixtureLabel is read by both but rendered by only one: this one. It is the fixture pill's copy here; <goma-highlight-card> draws two stacked team rows and uses the value only to name the synthesized event (which is what reaches the betslip leg). Its precedence. Explicit value, else `${homeTeam} - ${awayTeam}`, else whichever single name exists. Is identical in both.

Everything else is shared, means the same thing on both, and is spelled the same way.

The GomaPromoCard object, field by field ​

This is the contract a CMS should be built against. The CMS-owned column says whether an editor would author the value (artwork and copy) or whether it is provider data an integration would copy across from the sportsbook feed. Every field below is read by <goma-highlight-card> too.

FieldTypeRequiredCMS-ownedNotes
idstringyesprovider/editorStable identity, echoed back on every emitted detail. Not an event id: it is the card's own slot id.
imagestring (URL)noeditorThe hero artwork. No provider field carries it: it is the reason this widget is host-fed. Absent renders the strip as flat card colour.
imageAltstringnoeditorEmpty (the default) keeps the strip decorative (aria-hidden). Supply one only when the artwork carries meaning the copy does not.
eventIdstringno, but see notesproviderString, never Number(): provider ids are 18-digit snowflakes past Number.MAX_SAFE_INTEGER. Gates goma:event-navigate, is the favourite star's target (without it the star is not rendered), and is copied onto every betslip selection: a card without it produces picks that cannot be placed.
sportIdnumber | stringnoproviderResolves the sport glyph (see iconBaseUrl / sportIconUrl).
sportIconUrlstring (URL)noeditorPer-card override. Beats iconBaseUrl; both blank falls back to the flat glyph bundled in @gomagaming/core.
competitionIdstringnoproviderRides onto the synthesized event so it reaches the betslip leg. Nothing on the card renders it.
competitionNamestringnoproviderCompetition row copy, e.g. "La Liga".
venueIdstring | numbernoproviderRegion-flag lookup: ${flagBaseUrl}{venueId}.png.
flagUrlstring (URL)noeditorPer-card override of the venueId lookup, for hosts whose flags don't follow the CDN naming scheme.
marketNamestringnoproviderThe card's headline, e.g. "Goaler". Truncates rather than wrapping.
marketIdstringnoproviderCopied onto betslip selections and onto goma:pros-choice-select.
bettingTypeIdstring | numberno, but see notesproviderLoad-bearing. The provider rejects a bet whose leg carries no betting type. Omit it and the card still renders, but its picks are unplaceable.
eventPartIdstring | numbernoproviderbettingTypeId's pair: together they key a market. Rides onto goma:outcome-select.
startTimeepoch ms number, or an ISO 8601 stringnoproviderRendered as the two-line Today / 14:30 block, localised.
dateLabelstringnoeditorExplicit override that bypasses startTime date formatting.
timeLabelstringnoeditorExplicit override that bypasses startTime time formatting.
homeTeamstringnoproviderFeeds goma:event-navigate and the fixture pill.
awayTeamstringnoproviderSame.
fixtureLabelstringnoeditorPill copy override. Default is `${homeTeam} - ${awayTeam}`, falling back to whichever single name exists. No label and no team names → no pill. Read by <goma-highlight-card> too, where it names the synthesized event rather than painting a pill.
isFavoritebooleannoproviderOne-way seed for the favourite star: true merges eventId into the shared goma:favorites:v1 envelope once, and only when the player is signed in. It can only add, never remove, so it cannot clobber what <goma-favorites>' server sync resolved, and it never invents a favourite for a signed-out visitor. Identical behaviour in <goma-highlight-card>.
isLivebooleannoproviderRecorded on the synthesized event so a betslip leg is flagged live. Nothing on the card changes: there is no live badge, score or clock in the design.
outcomesOutcome[]nomixedThe odds buttons: the promoted market's outcomes, in the editor's order. Empty or absent → no odds row (the rest of the card still renders). Not selections: in this codebase a selection is a leg on a betslip, and reusing that word for market outcomes was actively confusing.
ctaCta | nullno (default null)editornull renders no CTA band.
ctaUrlstring (URL)noeditorTop-level shorthand for cta.url, accepted for the same record to work in both widgets. Declaring either one paints the band; cta.url wins when both are set.

Outcome ​

FieldTypeRequiredNotes
labelstringyes in practiceThe button's top line: the outcome name ("Bellingham, Jude"). name / outcomeName are accepted synonyms.
oddsnumberyes in practiceThe button's bottom line, as decimal odds. odd is an accepted synonym. Missing renders the button's loading spinner, not 0.00.
outcomeIdstringnoThe stable abstract outcome id. id is an accepted synonym. Falls back to bettingOfferId, then to a synthetic ${marketId}-o{index}. Keys betslip selection state, so supply the real one.
bettingOfferIdstringnoThe price-tied offer id. offerId is an accepted synonym. Both this and outcomeId reach the place-bet payload, and it is never fabricated: omit it and the leg carries null plus the betslip's own warning, because the provider rejects a leg carrying an offer id it never issued.
isAvailablebooleanno (default true)Fails open: only an explicit false locks the button (padlock glyph, not tappable).

Cta ​

FieldTypeRequiredNotes
labelstringnoOverrides the widget-level ctaLabel.
urlstringnoCarried in the emitted detail. The widget never follows the URL: routing to it is the host's job. It does, separately, emit goma:event-navigate on the same tap when the card carries an eventId, which is what opens <goma-game-details>.

Accepted synonyms ​

The canonical spelling is the one documented above. Four fields also accept alternatives, kept because they cost nothing and make a hand-written CMS record forgiving: both widgets accept exactly this set:

CanonicalAlso accepted
outcomes[].oddsodd
outcomes[].labelname, outcomeName
outcomes[].outcomeIdid
outcomes[].bettingOfferIdofferId
cta.urlctaUrl (top-level shorthand)

Fields this widget ignores ​

homeTeamLogo and awayTeamLogo are on the shared record but are read only by <goma-highlight-card>, which is the only card that draws team crests. They are harmless here: the same record can carry them for the other widget's benefit.

Only the dark "See other markets" link variant is built. The design's second, promotional gradient CTA is not implemented, which is why there is no type / variant field here.

Props / attributes ​

PropertyAttributeTypeDefaultDescription
cardcard (JSON)GomaPromoCard | nullnullThe single host-fed card object: the same record <goma-highlight-card> takes.
dataSourcedata-source'prop' | 'rpc' | 'subscribe''prop'Resolution mode. 'prop' is fully host-fed. 'subscribe' resolves the event and the named market from the live feed and keeps the odds ticking; 'rpc' resolves a one-shot priced snapshot and then releases its topics. Both auto modes need config.socketUrl: without one the widget warns once and degrades to the host-fed card. Any unsupported value logs one console warning and falls back to 'prop'.
sportEventLimitsport-event-limitnumber20Deprecated, no effect. The card resolves its event by id, so there is no sport feed to cap. Still accepted so existing pages do not break.
marketFallbackmarket-fallback'none' | 'first''none'Auto mode only. What to do when the resolved event does not carry the market the card named. 'none' renders the card with no odds row; 'first' falls back to the leading main market, which on this card is almost always the wrong thing, since the whole premise is one curated non-main pick.
matchOddsmatch-odds'auto' | boolean'auto'Auto mode only. Whether to also open the per-match {eventId}/match-odds feed, which carries the FULL market set. 'auto' (the default here) opens it whenever the card names a market: load-bearing on this widget, because a scorer market is NOT a main market, and the events feed streams only about 5 main markets per event, while the match-odds feed carries the rest. When defaultMarket (or marketId) names the market as {bettingTypeId}_{eventPartId} (for example 30_3), only that market's feed is opened, {eventId}/match-odds/{bettingTypeId}-{eventPartId} (tens of KB); a bare bettingTypeId opens every market of the match (several MB on a real operator).
ctaLabelcta-labelstring'See other markets'Default CTA copy. A per-card cta.label wins.
showCtashow-ctabooleantrueElement-level switch for the "See other markets" band. The card must also declare a cta (or the ctaUrl shorthand) before it paints: this suppresses the band across a whole rail without editing every card.
showFavoriteshow-favoritebooleantrueFavourite star. Hidden regardless when the card has no eventId.
showBordershow-borderbooleantrueThe 1 px gradient hairline around the card. One design palette hides it; the layout does not shift either way.
emptyMessageempty-messagestring''Message rendered when there is no card worth painting. Blank → the widget renders nothing at all, so a host page never gets a card-shaped hole.
heroHeighthero-heightnumber100Hero strip height in px.
heroObjectPositionhero-object-positionstring'center 34%'CSS object-position for the hero crop. The default reproduces the design's centre-biased crop of a ~1.73 : 1 source.
flagBaseUrlflag-base-urlstring''Region-flag base: ${flagBaseUrl}{venueId}.png. Blank falls back to the shared flag CDN.
iconBaseUrlicon-base-urlstring''Sport-icon base: ${iconBaseUrl}/icons/sports/dark_mode/{sportId}.svg. Blank falls back to the glyph bundled in @gomagaming/core.

Events ​

All goma:* events bubble and are composed: true (they cross the Shadow DOM boundary).

EventdetailNotes
goma:pros-choice-select{ id, eventId, marketId, marketName }Card body tapped. Always fires, including on a card with no eventId (in which case eventId is null). Host-only: no shipped widget listens for it.
goma:pros-choice-cta{ id, eventId, ctaType: 'markets', label, url }"See other markets" tapped. url is null when the card supplies none. ctaType is a constant today; it is carried so that a second CTA variant would be an added value rather than a changed payload. Host-only.
goma:event-navigate{ eventId, homeTeam, awayTeam, competitionName }Emitted in addition to either event above, but only when the card carries an eventId.
goma:outcome-select{ bettingOfferId, outcomeId, outcomeName, odds, eventId, marketId, bettingTypeId, eventPartId, line }Odds button tapped. Dispatched by the shared RegularOdds button, identical to every other card family.
goma:outcome-deselect{ bettingOfferId, outcomeId }A selected odds button tapped again.
goma:favorite-toggle{ type: 'event', id, eventId, isFavorite }Star toggled while signed in. id and eventId carry the same value.
goma:login-required{ reason: 'favorite', type: 'event', id }Star tapped while signed out. The widget does not write; the host opens its login flow.
goma:seo-data{ widget: 'pros-choice', type: 'sports-event', items: object[] }Fired once the card's event resolves, and again only when its identity changes: never on an odds tick. Suppressed until the resolved event carries a real team name: covers both auto mode's resolving state and the post-watchdog empty fallback, which would otherwise leave the same display-less record. See SEO data for the payload contract.
ready{}Widget mounted.
goma:error{ message, code, component? }Two paths. Connection layer: in an AUTO mode (subscribe / rpc), the WAMP adapter exhausts its 5 retry attempts (code: 'API_INIT_ERROR'), alongside goma:connection { state: 'failed' }. Widget root scope: an error raised in the widget's own outermost component (its setup, its render, its lifecycle hooks or its watchers), where code is the error's name, e.g. 'TypeError' ('RUNTIME_ERROR' when it has none). Errors raised inside the widget's child components do not reach this event: the in-shadow error boundary catches those and shows the retry banner instead, emitting only goma:retry.
goma:retry{}User clicked Retry on the render-error banner.

An odds tap never also activates the card, and a CTA tap never also emits goma:pros-choice-select: both are stopped at the source, for pointer and keyboard alike.

Integrates with ​

The card listens for nothing: it has no follow* prop, because there is no picker whose selection would re-scope a single host-curated card.

It cooperates downstream through two shared envelopes:

  • goma:betslip:v4: odds taps write selections through useSharedBetslipSync({ subscribeToState: false }): identity fields only, opting out of slip-level state exactly as <goma-events-horizontal> and <goma-hero-card> do. Drop a <goma-betslip-floating> or <goma-betslip-sidebar> anywhere on the page and a tap here lands in it with no host glue.
  • goma:favorites:v1: the star writes the events array. A fixture starred here lights up in <goma-favorites>, in <goma-highlight-card> and in every game card on the page, and vice-versa.

goma:event-navigate is followed by <goma-game-details> (its followEventNavigate is on by default), so both a card-body tap and the "See other markets" link open the full event with no host code: provided the card carries an eventId.

See Cross-widget wiring and Shared state for the full contracts.

Favourite-star scope: a documented judgement call ​

The star is drawn beside the competition name, on a card that promotes a market on one fixture, and the design does not say which of the two it favourites. This widget treats it as an event favourite: what a player is starring is the fixture in front of them, which is what the sibling <goma-highlight-card> and every game card store. Scoping it to the competition instead made one tap light up every card in the same league, which is not what the gesture means. A card with no eventId therefore renders no star at all rather than a control that silently writes nothing.

Empty / loading / error states ​

There is no loading state: card is host-fed and synchronous, so the widget renders its final state on the first tick. None of the states below are designed (the Figma source has no empty, loading, error, suspended, odds-ticking or live treatment for this card) so each is a deliberate, conservative choice:

  • Nothing rendered: card is null (the default), an array, an unparseable JSON string, or an object carrying no image, marketName, competitionName, team names, fixture label or outcomes. The element occupies no visual space rather than drawing an empty shell. <goma-highlight-card> asks this of the same fields, so one record is "empty" in both or in neither.
  • Partial card. A card with some fields renders the parts it has: no outcomes drops the odds row (and the whole bottom band when there is also no cta), no fixture names drop the pill, no image leaves a flat card-coloured strip.
  • Locked outcome: an outcome with isAvailable: false renders the shared padlock treatment and is not tappable. Availability fails open: a card that never mentions it is fully tappable.
  • Odds tick: when a host replaces card with a new object carrying new prices, the shared RegularOdds button runs its standard green/red change animation for 3000 ms. The widget has no feed of its own, so a host that wants ticking odds must re-assign card.
  • Named market not found (auto modes). No odds row, and one console warning after 5 s. See Choosing the market.
  • Render error: an onErrorCaptured boundary shows a role="alert" banner (ERROR: <message>) with a Retry button (clears the error and emits goma:retry) and a dismiss ×, auto-clearing after 8000 ms.
  • Unimplemented dataSource: logs exactly one console.warn and renders the card in 'prop' mode.

Theming ​

Every colour maps to an existing --goma-* token: this widget introduces none:

TokenRole
--goma-backgroundCardsCard fill; also the fixture pill's inner surface (so the pill reads as transparent while masking its gradient edge)
--goma-backgroundSecondary"See other markets" link background
--goma-textPrimaryMarket headline, kick-off time
--goma-textSecondaryCompetition name, kick-off date, CTA label
--goma-iconPrimarySport glyph
--goma-iconSecondaryCTA chevron
--goma-favoritesFavourite star
--goma-allWhiteRegion-flag ring
--goma-highlightPrimaryDivider mid-stop, fixture-pill text, every focus ring
--goma-backgroundOdds / --goma-textOddsOdds buttons at rest
--goma-buttonBackgroundPrimary / --goma-buttonTextPrimarySelected odds button
--goma-backgroundDisabledOdds / --goma-textDisabledOddsSuspended odds button
--goma-separatorLineCTA link border
--goma-cardBorderLineGradient1 / 2 / 3The card's 1 px hairline and the fixture pill's edge: the pill paints the same three stops in the reverse direction, which is what the design draws
--goma-scrollThe odds row's scrollbar when it overflows (see below)
--goma-backgroundBorderLoading skeleton bars

The widget root is transparent: the host owns the surrounding panel and the rail background. The card is fluid: it fills its container's width, with a 100 px hero strip and an 8 px radius. See Theming for the complete reference.

No font is bundled. GomaPulse widgets ship no typeface and register no @font-face inside the Shadow DOM (unreliable on Firefox), so the card renders in the shared :host stack from @gomagaming/core ('Open Sans' followed by the system fallbacks) which resolves to whatever the page has actually loaded. The design's Exo 2 is a host concern; load it on the page and override font-family on the element to use it.

Appearance (styling object) ​

appearance carries structure only. Each key is a single value (identical in light and dark) that maps to one --goma-* CSS custom property on the host. Set it via el.appearance = {...} or the appearance attribute (a JSON string); unset keys keep the default, so an empty {} (the default) reproduces the current look. Colours stay in theme.

KeyCSS variableDefaultAffects
oddsLabelFontSize--goma-oddsLabelFontSize0.62remOutcome name above the price on each odds button. It stays one line with an ellipsis.
oddsLabelLineHeight--goma-oddsLabelLineHeight1.25Line height of that outcome name.

The same two keys exist on <goma-highlight-card>: both cards draw the shared odds button.

i18n / message overrides ​

Almost all copy on this card is host-fed, because it is a CMS surface: the market name, the outcome labels, the competition name, the fixture and the CTA label all come from card (or from ctaLabel, whose default is the plain English string "See other markets"). The widget adds no new i18n keys. The bundled keys its tree does reach are all pre-existing shared ones:

KeyDefault (en)Used by
today"Today"Kick-off date line for a same-day fixture.
tomorrow"Tomorrow"Kick-off date line for the next day.
add_to_favorites"Add to favorites"Star aria-label when not favourited.
remove_from_favorites"Remove from favorites"Star aria-label when favourited.
outcome_locked"Outcome locked"aria-label of an unavailable odds button.
odds_increased"Odds increased to {value}"Live-region announcement on an upward odds change.
odds_decreased"Odds decreased to {value}"Live-region announcement on a downward odds change.
retry"Retry"Render-error banner's retry button.

Embedding examples ​

Vanilla / CDN ​

The host owns the rail: loop your own array of picks and give each element one card:

html
<div class="pros-choice-rail"></div>
<script type="module">
  import '@gomagaming/pros-choice'

  const picks = await fetch('/api/cms/pros-choice').then((r) => r.json())
  const rail = document.querySelector('.pros-choice-rail')

  for (const pick of picks) {
    const el = document.createElement('goma-pros-choice')
    el.config = { locale: 'en' }
    el.card = pick
    el.addEventListener('goma:pros-choice-cta', (e) => {
      // e.detail = { id, eventId, ctaType, label, url }
      if (e.detail.url) window.location.href = e.detail.url
    })
    rail.appendChild(el)
  }
</script>
css
/* The rail is yours: the design lays the cards out with an 8 px gap. */
.pros-choice-rail {
  display: flex;
  gap: 8px;
  overflow-x: auto;
}
.pros-choice-rail > goma-pros-choice {
  flex: 0 0 auto;
  width: 348px;
}

React ​

jsx
import '@gomagaming/pros-choice'
import { useEffect, useRef } from 'react'

function ProsChoiceCard({ card, onCta }) {
  const ref = useRef(null)
  useEffect(() => {
    const el = ref.current
    if (!el) return
    el.config = { locale: 'en' }
    el.card = card
    const handler = (e) => onCta(e.detail)
    el.addEventListener('goma:pros-choice-cta', handler)
    return () => el.removeEventListener('goma:pros-choice-cta', handler)
  }, [card, onCta])
  return <goma-pros-choice ref={ref} />
}

export function ProsChoiceRail({ picks, onCta }) {
  return (
    <div style={{ display: 'flex', gap: 8, overflowX: 'auto' }}>
      {picks.map((card) => (
        <ProsChoiceCard key={card.id} card={card} onCta={onCta} />
      ))}
    </div>
  )
}

Vue ​

vue
<script setup>
import '@gomagaming/pros-choice'
import { ref, watchEffect } from 'vue'

const props = defineProps({ picks: { type: Array, default: () => [] } })
const els = ref([])

watchEffect(() => {
  els.value.forEach((el, i) => {
    if (!el) return
    el.config = { locale: 'en' }
    el.card = props.picks[i]
  })
})
</script>

<template>
  <div class="rail">
    <goma-pros-choice
      v-for="(pick, i) in picks"
      :key="pick.id"
      :ref="(el) => (els[i] = el)"
      @goma:pros-choice-cta="$emit('cta', $event.detail)"
    />
  </div>
</template>

Accessibility ​

  • The card is a div role="button" with tabindex="0" and an aria-label built from the market name, fixture and competition; not a native <button>, because it nests other interactive controls. Enter and Space activate it.
  • Every nested control stops both click and keydown from reaching the card, so the star, an odds button and the CTA each fire exactly one intent.
  • The favourite star is a native <button> carrying aria-pressed and a state-dependent aria-label.
  • Odds buttons carry aria-pressed for selection state, aria-disabled when the offer is unavailable, an aria-label of "<outcome>, <odds>", and an aria-live="polite" region announcing odds changes.
  • The hero image is aria-hidden unless the host supplies imageAlt; the sport glyph, region flag and gradient divider are decorative and hidden from assistive technology.
  • Card, star, odds buttons and CTA all carry a visible focus-visible ring (--goma-highlightPrimary).

What's deferred ​

  • Outcome-level resolution from bettingOfferId alone. The auto modes resolve a card from { eventId, marketId }: see Data sources. What is not built is the <goma-suggested-bet>-style path that takes a bare outcomes: [{ bettingOfferId }] and walks the betting store's global entity maps back to its outcome and market. A card that wants specific outcomes still names its market and takes the market's own outcomes.
  • The promotional CTA variant. The design's second ctaCard variant (a green gradient band with a lightning glyph and a brand wordmark) is not built. Only the dark "See other markets" link ships, which is why cta has no type field and the widget introduces no gradient tokens.
  • The rail. No carousel, arrows, dots, edge fades, peek or section title are inside the widget; the design source has none either (its rail frame is three cards in a plain row). The host lays them out.
  • A refresh / cycle glyph. Described in early briefs but absent from the design source: the competition row ends at the name. It is not built and no goma:pros-choice-refresh event is emitted. Should it be confirmed, the widget would still only report the tap: it has no alternative-pick source, so cycling is the host's job (swap the object in card).
  • Live-match treatment. No LIVE badge, score or clock. card.isLive is recorded on the betslip selection but changes nothing visually, because nothing is designed for it.
  • A designed empty / loading / error state. See Empty / loading / error states: every state there is a conservative fallback, not a design.
  • More or fewer than three outcomes. The design draws exactly three full-width buttons and specifies nothing else. In the auto modes the card shows the first three priced outcomes of the market, in the order the feed sends them (never re-sorted). An outcome the provider lists but never prices, such as a scorer market's "None", is left out rather than shown with a price that never arrives; it appears in its feed position if an offer is created for it later. Fewer than three priced outcomes stretch to fill the row. A host-fed card shows every outcome the host supplies: four or more hold an 88 px minimum and the row scrolls horizontally rather than crushing the labels.

Source layout ​

LayerFile
Element classpackages/pros-choice/src/ProsChoiceElement.js
App wrapperpackages/pros-choice/src/ProsChoiceApp.vue
Cardpackages/pros-choice/src/components/ProsChoice/ProsChoiceCard.vue
Market headline / fixture pill / odds rowpackages/pros-choice/src/components/ProsChoice/{ProsChoiceMarketHeadline,ProsChoiceFixturePill,ProsChoiceOddsRow}.vue
View-model composablepackages/pros-choice/src/composables/useProsChoiceCard.js
Per-widget CSSpackages/pros-choice/src/styles/card.css
Shared card piecespackages/ui/src/components/PromoCards/{PromoCardShell,PromoCardHero,PromoCardCompetitionRow,PromoCardKickoffTime,PromoCardCtaLink}.vue
Shared odds buttonpackages/ui/src/components/GameCards/RegularOdds.vue
Shared region flagpackages/ui/src/components/common/CountryFlag.vue
Cross-widget storage syncpackages/sports-domain/src/shared-state/{useSharedBetslipSync,useSharedFavoritesSync}.js
Theme-token registrypackages/pros-choice/src/themeTokens.js
Playground entrypackages/pros-choice/src/playground.manifest.js + playground/src/registry.ts
Testspackages/pros-choice/tests/{ProsChoice,useProsChoiceCard,playground-manifest}.test.js

Integration Caveats ​

  • SSR/webview: importing this package outside a browser previously threw immediately; fixed with a guard on the shared element factory.

  • Initialization: this widget shares @gomagaming/core's i18n singleton with every other widget on the page; mounting two widgets with different locale values on one page is not currently isolated per instance (a known constraint, not fixed in this pass).

  • Configuring before the definition loads. Assigning config (or any property) while the element is still an unknown element is safe: the value is replayed through the widget when it upgrades. That matters when loading from the CDN, whose loader imports widget modules asynchronously, so your script usually runs first. On release sets at 0.34.0 and earlier the value was silently discarded and the widget mounted with no configuration and a clean console: there, await window.__gomaCdn.ready first, or configure through attributes. See Setting a property before the widget's definition has loaded.

See Integration Caveats for the cross-widget picture.

Changelog ​

Current release: 0.37.11. Every GomaPulse package shares one version number and they are published together, so a release that changes nothing here still bumps this widget. Only the releases that changed it are listed below.

Every release that changed this widget, newest first. The latest is open; the rest expand.

0.37.9
  • Favourites: the list always belongs to the player who is signed in now, quick star taps are no longer lost, only real star taps are sent to the server, a failed load is tried again and then shows an error with Retry, and <goma-favorites> no longer waits for the socket login. Star taps on game details, hero card, highlight card, pros' choice, competitions list and banners are now saved to the server when favoritesApiBaseUrl is set and a signed-in session is available on the page (on a page without <goma-favorites>, that means the socket login has finished). A late rejection of a session that already ended (replaced by a new login, or signed out) no longer signs out the current one; a rejection of a new session still does.
0.37.7
  • Faster pages with suggested-bet and promo cards on a real operator, with less data downloaded.

    • @gomagaming/sports-domain: mapping a market dump no longer slows down as the store grows. Outcomes find their offer, and markets find their outcomes, through a lookup built once per dump instead of a scan per item. The results are the same.
    • @gomagaming/sports-domain: subscribeToMatchOddsAdditive and bannerEvents.subscribeForMatchOdds accept bettingTypeId and eventPartId. With both, they open that one market's feed, {matchId}/match-odds/{bettingTypeId}-{eventPartId}, instead of every market of the match. subscribeForMatchOdds no longer re-opens a topic that is already open on the same session.
    • @gomagaming/network: a topic stays registered for 3 s after its last listener leaves, so a widget that closes and reopens it at once does not download its initial dump again. A listener that joins a topic whose initial dump failed, or has had no answer for 10 s, requests the dump again. unregisterGraceMs sets the window, and 0 restores the immediate release.
    • @gomagaming/suggested-bet: in recsys mode each leg opens only its recommended market's feed.
    • @gomagaming/pros-choice: a card that names its market as {bettingTypeId}_{eventPartId} opens only that market's feed. In the auto modes the card shows the first three priced outcomes in feed order, and leaves out outcomes the provider never prices instead of showing a price that never arrives.
    • @gomagaming/highlight-card and @gomagaming/pros-choice: a card whose named market is not on its event logs one console warning after 5 s, listing the markets the event does offer.
0.37.4
  • An event that a feed delivers again now keeps its markets. Before, when a favourite competition was collapsed and opened again, or its time window or sport changed, the event card in <goma-favorites> showed "Suspended" instead of its odds, because the markets were already stored and the event was built without them. The same fix applies to the banner, suggested bet, highlight and pros choice cards, which load their events the same way. An event only receives the stored markets that belong to it, and those markets take the state of the new data: a market that is open again is no longer shown as suspended, and an offer that the provider withdrew while the section was closed is no longer shown at its old price.

  • A card or banner that names an eventId now finds its event by id. Before, the widget subscribed to the whole feed of the event's sport and looked for the event inside it, so an event outside that feed (a far-future match, for example) never resolved and its card stayed empty. Now the widget asks the provider for the events it needs, live or prelive, so any event the provider knows resolves, and its score, clock, odds and live status keep updating. The events feed carries no main-market ranking, so for each sport shown the widget makes one small extra request for it, to keep a banner's headline market stable when a market is suspended and offered again.

    <goma-multi-banners>, <goma-highlight-card>, <goma-pros-choice> and <goma-suggested-bet> no longer need a sportId to resolve an event. The sportEventLimit property and the sport-event-limit attribute are deprecated and now do nothing. They are still accepted, so no existing page breaks.

    On the favourites page, live score, clock and status now update after the page loads. In the suggested bet recommendation mode the events also receive live match updates now; the card does not show a score, so nothing changes on it.

    Sport banners in <goma-multi-banners> can show the market name in a pill under the team names. Set showMarketName on the widget, or on one banner entry, to turn it on.

  • A live event's markets are now ranked by the feed's live main-market list. A feed that carries both live and pre-match events declares one list for each, and they can put different markets first: for basketball, the pre-match list leads with Over/Under and the live list leads with Home Away. Before, every event was ranked by whichever list came first in the feed, so a live game could lead with the pre-match main market. This applies to the event cards in <goma-events-horizontal>, <goma-favorites> and <goma-competitions-list>, and to the main market that <goma-multi-banners>, <goma-highlight-card> and <goma-pros-choice> pick when none is set. orderMarketsByMainMarkets takes an optional { isLive } and resolveMarketSelection an optional isLive option for this. A feed that declares only one list ranks every event by that list, as before.

    • <goma-suggested-bet>: custom-elements.json now lists the defaults for the icon-base-url attribute ('') and the flagBaseUrl property (the built-in flag CDN).
    • Playground theme-token lists (./playground export) now match what each widget paints: <goma-hero-card> now also lists --goma-favorites (the favourite star), alongside --goma-backgroundOdds (the odds buttons, painted by the shared odds button); <goma-top-competitions> drops --goma-iconSecondary, which it never paints (the sport glyph uses --goma-iconPrimary); <goma-highlight-card> and <goma-pros-choice> add --goma-backgroundBorder, the colour of their loading skeleton bars.
    • <goma-live-chat>: on a browser that cannot format compact numbers, the participant count now reads like the normal compact output (25K, 2M, and 1M for 999,999) instead of keeping a trailing .0 (25.0K).
  • Game cards: a suspended odds button now paints backgroundDisabledOdds. The resting backgroundOdds class used to stay on the button and won the cascade, so a theme's suspended background never showed. The "Suspended" label shown when a whole market is suspended now reads textDisabledOdds, the same token as the suspended odds button, instead of inputTextTitleDisabled (this reverses the token choice of a separate change). The suspended button's padlock is now the design's plain 14px padlock (core's lock.svg, its only user) instead of a padlock inside a refresh ring. Affects the game cards of events-horizontal, favorites, competitions-list, game-details, hero-card, multi-banners, highlight-card and pros-choice.

0.37.0
  • The odds button's outcome name (the line above the price) reads its size and line height from --goma-oddsLabelFontSize and --goma-oddsLabelLineHeight, which fall back to the old 0.62rem and 1.25. <goma-pros-choice> gains an appearance object and, like <goma-highlight-card>, accepts oddsLabelFontSize and oddsLabelLineHeight. The name stays one line with an ellipsis. Without these keys every odds button renders exactly as before. @gomagaming/core is bumped for the two new utilities in its compiled sheet.
0.36.1
  • Playground manifests (@gomagaming/<widget>/playground) now offer a control for every setting each widget really has. Settings that cannot be a form field (raw provider data, read-only state mirrors, props a widget never reads) are recorded with a reason in the repo instead of being silently missing.

    Controls that never reached the widget are fixed:

    • The betslips' placeBetHeadersJson / placeBetBodyJson / quickAddAmountsJson are now placeBetHeaders / placeBetBody / quickAddAmounts.
    • The time-filter and filters-settings ticks editors set ticks.
    • live-chat's layout control writes position, the config key the element reads.
    • The sports-navigation widgets drop the unread sportsJson.
    • JSON-typed defaults are real objects and arrays instead of serialized strings. The top-competitions highlightedImages string default was indexed character by character and requested bad image URLs on every load. The missions-hub emptyStateImages illustrations now show.

    No widget runtime behaviour changes.

0.36.0
  • Moved every shared UI component out of @gomagaming/sports-domain into a new @gomagaming/ui package, which depends on it:

    • The game cards: RegularOdds and its live/prelive/outright/market variants, SkeletonCard and CountryFlag. Used by every widget that lists events.

    • The betslip family: BetslipPanel and its selection, summary and bonus pieces, and the GomaDialog modal shell. Used by the betslip floating and sidebar widgets.

    • The missions family: the missions cards, level rows, tags, empty state and mission-detail panels. Used by the missions hub and mission details widgets.

    • The promo-card family: the promo card shell, hero, competition row, kickoff time, CTA link and skeleton. Used by the highlight card and pros' choice widgets.

    MyBetsPanel and MyBetsCard moved into @gomagaming/my-bets, their only consumer. The betslip widgets keep showing them through <goma-my-bets>.

    No widget's public surface, markup or behaviour changes, and each widget renders the same components as before. @gomagaming/ui is installed automatically with the widgets that use it.

    @gomagaming/sports-domain no longer exports any component. It keeps the stores, API clients, composables and the promo-card auto-mode layer, and additionally exports useBetslipSystem, useBonusSessionGate, bonusRowTitle, convertOdd, formatRatePercent, formattedDate, formattedTime and resolveDateLocale. A host importing one of the moved components from @gomagaming/sports-domain directly should import it from @gomagaming/ui (or, for the MyBets pieces, use <goma-my-bets>) instead.

0.35.0
  • The shared betslip moved to goma:betslip:v4, and the odds-change setting it carries is now a three-value policy.

    The envelope used to carry acceptOddsChange, a boolean that could express only two of the three settings the betting provider accepts, and that meant the opposite of what its name said: true was submitted as ACCEPT_HIGHER, which accepts a rise. Its replacement, oddsChangeBehavior, says which of accept-any, accept-higher and never-accept applies, and the last of those is a setting the boolean could not represent at all: a bet that is cancelled if the odds move in either direction.

    The storage version moved rather than the field changing meaning under the same key, so a page still running the previous release keeps reading the envelope it wrote.

    Nothing is required of an integrator. An existing betslip migrates on the first widget mount, carrying the slip, stake, tab and bonus selections across. Only the odds setting changes, and every existing betslip resolves to accept-higher:

    • A betslip with acceptOddsChange: true, the previous default, was already being submitted as ACCEPT_HIGHER. Nothing about those bets changes.
    • A betslip with acceptOddsChange: false was being submitted as ACCEPT_ANY, under a control reading "Approve higher odds", which is not what that value does: it also accepts a DROP in the odds. Those bets are now submitted as the label promised. A player who wants any change accepted can select it explicitly.

    An integrator reading the betslip out of storage should read oddsChangeBehavior, and address the key through the newly exported BETSLIP_STORAGE_KEY rather than a literal. Widget code that sets the old boolean keeps working: it is still available, derived from the policy.

  • Secondary market cards no longer swap their two sides mid-game, and a betslip row can no longer become a different bet without saying so.

    The card. A live market that suspends and re-offers arrives as DELETE+CREATE, so the re-created outcome lands on the tail of market.outcomes and the market reaches the card with its two sides the other way round. sortOutcomes only ranks outcomes carrying a headerNameKey, which a handicap outcome does not, so every pair compared equal and Array#sort's stability handed the layout to arrival order. For a two-sided market the comparator now falls through to the provider's outcome code: numbered codes (#1 / #2) compare numerically, so #10 does not sort before #2, and the provider's named pairs (#Y/#N, #O/#U, #Odd/#Even) are ranked the way getOutcomeOrder already ranks them. Yes before no, over before under, odd before even. A pair of codes the comparator does not recognise keeps the order the feed sent, and a market with more than two outcomes is never reordered. sortOutcomes returns a copy, so the call sites that hand it a store-backed market's own array no longer reorder the store from inside a computed.

    <goma-events-horizontal>, <goma-favorites> and <goma-competitions-list> render the shared secondary card and inherit the fix. <goma-game-details>, <goma-hero-card>, <goma-highlight-card>, <goma-multi-banners> and <goma-pros-choice> call sortOutcomes on their own market lists and render the shared odds button, so the new ordering and the new line on goma:outcome-select reach them too.

    The betslip. RegularOdds records the market's line (paramFloat1) on the selection at tap time, and it now survives both cross-widget bridges: the goma:betslip:v4 envelope carries it as an identity field, and goma:outcome-select carries it in its detail. Without that it never reached the widget that reads it. useBetslipOddsSync compares it against the market on every pass, in the game-details store slots as well as the list ones: when the line has moved the row is re-captioned and marked unavailable, and useBetslipLogic refuses to place a slip holding an unavailable leg, so the player picks the new line deliberately instead of having it accepted for them by the odds-change gate. The tapped line is never overwritten, so the verdict is re-asserted on every tick rather than being undone by the next price update, and a line that moves with no price change is caught too: a market-only rewrite never woke the odds watcher before. A wording-only change still just corrects the caption, and a selection saved before the line was recorded is never invalidated by the upgrade.

    Saying why. A blocked leg now carries lineMoved: true beside isAvailable: false, so the two reasons a leg cannot be placed stay apart. The betslip row reads "Line changed" (selection_line_moved, added to the en/fr/pt bundles) instead of "Selection unavailable" (the match is still on, it is the line that is gone) and goma:bet-failed lists those legs under a new movedLineSelections key rather than folding them into unavailableSelections, so a host does not word a live match as dead.

    placeBetBlockedReason gains 'line_moved', ahead of 'unavailable', so a host keying its wording off that token can say that the line moved rather than that the match is gone; the widget's own copy carries the new place_bet_blocked_line_moved key in all three locales. The flag itself is now written in both directions: a line that moves and comes back clears it, so a later genuine withdrawal reports as unavailable and not as a moved line.

  • A sport with no bundled icon now falls back to a real glyph instead of the browser's broken-image icon.

    SbIcon resolves a bundled SVG by name and otherwise falls through to an <img>. For a sport id the feed ships before its artwork exists (e-Volleyball, in the sports carousel and in the "Sports available" list) that <img> 404s, and the old error handler only faded it to opacity: 0.2, which leaves the broken-image glyph on screen at 20%. A failed <img> is now dropped from the render instead.

    What stands in is the sport's family glyph: every e-sports category falls back to the e-sports icon, which is a real icon rather than "no icon". The family is read from the sport list the widget was already fed (the parent whose childrenIds names the category) so no id list has to be kept in step with the provider, and a flat feed that names no parent simply falls back to the neutral noicon glyph as before. The new sportIconNames helper in @gomagaming/core is what the sport tiles, the picker's trigger pill and the settings modal's sport row all resolve through.

    A host serving its own sport artwork keeps priority, and keeps it whether or not it configured a base URL: ${iconBaseUrl}/icons/sports/dark_mode/{iconId}.svg when one is set, and the origin-relative /icons/sports/dark_mode/{iconId}.svg when it is not. iconBaseUrl has no value that means "my own root" ('' and '/' both read as unset), so a host serving icons from its root keeps working with no configuration at all. A stand-in is considered only once that request has failed.

    Every widget that draws a sport glyph through SbIcon inherits the change, which is why they are all listed here: the two sports pickers, the settings modal, both top-competitions widgets, the sport banners, the game-details header, the suggested-bet legs, the My Bets cards, the two betslips that embed them, the highlight card and the Pros' Choice card.

    The family is looked up across the whole sport list the feed sent rather than the slice on screen. In rpc/subscribe mode useSportsSource mirrors the betting store filtered to the current context (LIVE vs popular), and a parent category carries no events of its own, so it is routinely missing from that slice: every e-sports child would have fallen back to the neutral glyph. The unfiltered list is mirrored alongside it as state.sportsCatalog, and sportIconLookupList (new in @gomagaming/core) is what the tiles, the trigger pill and the settings row read. mapSport also refreshes childrenIds on an existing entry: the list was written only when the entry was first created, so a family relation that arrived on a later dump, or a child added to one. Never reached the widget.

0.34.0
  • Stop a region list fetched for the wrong sport from overwriting the right one, and parse numeric strings assigned to numeric widget properties instead of silently replacing them with the widget's default

    A <goma-region-filter> configured for any sport other than Football issued two location requests instead of one: its own, correctly scoped to the configured sport, and a second one (scoped to Football) that nothing had asked for. Both wrote the same place, so the pills the widget rendered were whichever response happened to come back last. The same race reached <goma-competitions-list>, which groups its competitions by the same region data.

    The unrequested request came from the per-widget boot routine, which fetched locations with no sport at all and so fell back to sport 1. Locations are scoped to a sport on the server, and boot has no sport to scope them by, so it no longer fetches them: the only readers of that data are the widgets above, and each of them already fetches it for the sport it is showing.

    That removes the collision at its source. As a second line of defence, a locations payload now records which sport it was fetched for, and a widget ignores one belonging to a sport it is not showing. This matters whenever two scoped fetches can overlap (switching sport while a request is still in flight, for instance) and it is deliberately narrow: a payload with no recorded sport is still rendered, so the guard can only ever suppress data it can positively identify as belonging to somewhere else.

    Separately, every numeric property on every widget (sportId, skeletonCount, pageSize, spaceBetween, autoplayInterval and the rest) used to accept a JavaScript number and nothing else. A numeric string was not parsed; it was discarded and quietly replaced by the property's default value. el.sportId = '8' therefore left the widget on sport 1 and fetched the wrong data, with nothing logged to say so. Three ordinary integration paths hand over a string: a router parameter, a value read out of a CMS or JSON configuration, and a framework re-render that stringifies its props. Numeric strings are now parsed. Values that are not a finite number and not a numeric string (null, undefined, '', booleans, objects, NaN, Infinity) still fall back to the default, as before. HTML attributes (sport-id="8") and the config object were never affected by this and are unchanged.

  • Signing in (<goma-betslip-sidebar>, <goma-betslip-floating>, <goma-favorites>) no longer stalls on a WAMP socket the provider never answers.

    The EveryMatrix gateway sometimes hands out a socket that completes the handshake and then answers no call at all, not the feeds, not /sports#loginWithCeSession. The CE-session exchange only retried once a step settled, so it stalled for good: the host's "session ready" wait ran out, and every bet went out without a username and came back rejected until the page was reloaded. Around a quarter of sign-ins hit it on stage.

    Each exchange step (the StartInfo request and the loginWithCeSession call) now has a 5s deadline, tunable with the new sessionStepTimeoutMs key (0 disables it). A timed-out StartInfo request is aborted and retried. A timed-out loginWithCeSession drops the shared connection instead of retrying into it (every widget reconnects and the exchange re-runs on the fresh socket) at most twice per session; a successful exchange restores that allowance. If no new connection follows a replacement, a console warning says the exchange is not running.

    <goma-multi-banners>, <goma-highlight-card> and <goma-pros-choice> now re-open their live topics after a reconnect. They skipped every sport they had already subscribed to, so after any socket loss, and now after a replaced login socket, their odds stopped updating until the element was unmounted.

0.32.0
  • Widgets no longer dispatch a bare error event alongside goma:error.

    Every factory-built widget's app-level Vue error handler dispatched two events for the same failure: goma:error, and an un-namespaced error. The bare one is gone; goma:error is unchanged and still carries the same { message, code, component? } detail.

    Like every GomaPulse event it was composed and bubbling, so it did not stop at the widget. It crossed the shadow root, crossed the document, and arrived at whatever the host had on window.addEventListener('error', …): as a CustomEvent with message, error and filename all undefined, since those are ErrorEvent fields a CustomEvent does not have. A host with error reporting installed received a malformed error record it could neither read nor attribute.

    That had been written off as harmless on the grounds that the handler behind it could never fire. It can. Vue begins its errorCaptured walk at the throwing component's parent, so the onErrorCaptured boundary each widget installs in its own root component cannot see that component's own errors, and being the root, there is nothing above it to catch them either. Anything raised in a widget's root scope (its setup, its render, its lifecycle hooks, its watchers) skips the boundary and reaches the app-level handler every time. Only errors from child components are caught by the boundary, which is what it is for: those show the in-shadow banner and emit goma:retry.

    Hosts listening for goma:error need no change. A host listening for bare error on a widget element should move to goma:error; it was documented as unreachable, so in practice it delivered nothing until now.

0.30.2
  • Event cards: line the odds row up across a rail, and settle the market badge

    Every card now distributes its rows in a flex column and pins the odds row to the bottom. Previously the main cards simply stacked their rows inside a fixed height panel: the stack ran taller than the panel, so the odds row spilled past the card and sat several pixels below the odds row of the secondary market cards beside it. The market badge is now centred in the gap between the last team name and the odds buttons, and the secondary card draws the same hairline under its top row that the main cards have always drawn.

    The card is also slightly taller: the default size goes from 156px to 160px and the large from 188px to 190px, and at large the gap above the team names comes down from 12px to 8px. The design frames set two 16px team names in 16px line boxes, which shears the glyphs on the web once the names are truncated, so the names are sized to their ink instead: height the frames never budgeted for. The heights stay EXACT rather than becoming minimums: a rail must never show cards of differing heights. The "See all" tile, the loading skeleton and the empty state move with them, being the same card.

    The suspended / no-odds block picks up the same transparent one pixel border the odds buttons carry, so the two occupy identical boxes rather than differing by a border width.

    Promo cards (goma-highlight-card, goma-pros-choice): the loading skeleton is now the same height as the card it stands in for, so a rail no longer jumps when the feed answers. goma-pros-choice also reserves the fixture pill's height when a card carries no fixture, so cards keep one height with or without it.

0.29.0
  • Add goma:seo-data: widgets emit their resolved event data so hosts can build their own meta tags and structured data.

    Eight widgets emit the event: game-details, events-horizontal, competitions-list, hero-card, highlight-card, pros-choice, multi-banners (sport banners only), and casino-games. The payload is { widget, type, items }, where type is 'sports-event' or 'casino-game'. The event bubbles and is composed, so a single listener on document catches every widget on the page.

    Emission fires once when data resolves and again only when the data's identity changes: markets, score and clock are excluded from the identity signature, so odds ticks do not re-fire it. An empty items array is never emitted. Hosts must replace their structured data on each event rather than append.

    Widgets carrying a signed-in user's own data, private content, or page chrome deliberately do not emit.

0.28.0
  • Add two single-card, fully host-fed promo widgets that take one and the same card record. Both render one card per element (no carousel, arrows, dots or section header) so a host that wants a rail loops its own array and assigns one object per element, the shape <goma-suggested-bet> already uses. Both are CMS-driven and ship two modes. In manual mode (dataSource: 'prop', the default) every field arrives on el.card (a live object or a JSON string) and nothing is read from a socket. In auto mode (dataSource: 'subscribe' for live, 'rpc' for a one-shot snapshot) the host supplies only what a CMS owns (the image, an eventId and a defaultMarket) and the widget resolves teams, competition, kick-off, market name, outcome labels and odds from the feed, keeping prices ticking. Auto mode needs config.socketUrl. Host-supplied display fields win over the feed; identity and price always come from the feed. Neither widget adds a theme token.

    One record shape for both. The two widgets ship together and are meant to be authored from a single CMS record type, so they take the same object field for field: id, eventId, image / imageAlt, competitionName / competitionId, sportId / sportIconUrl, venueId / flagUrl, homeTeam / awayTeam / fixtureLabel, startTime / dateLabel / timeLabel, marketName / marketId, bettingTypeId / eventPartId, isLive, isFavorite, outcomes: [{ outcomeId, label, odds, bettingOfferId, isAvailable }] and cta: { label, url }. Paste the same object into either element and it renders; paste it into both and an odds tap produces an identical betslip leg. There are exactly two exceptions, both by design: homeTeamLogo / awayTeamLogo are read only by <goma-highlight-card>, the only card that draws team crests; and fixtureLabel is read by both but painted only by <goma-pros-choice>, as its fixture pill. The highlight card draws two stacked team rows and uses the value to name the event instead. Integrators build one mapping table, not two.

    A short list of synonyms is accepted so a hand-written record is forgiving, identically in both widgets: odd for odds, name / outcomeName for label, id for outcomeId, offerId for bettingOfferId, and a top-level ctaUrl shorthand for cta.url. The canonical spelling is the one documented on each widget page; the synonyms are accepted, not recommended.

    <goma-highlight-card>. A highlight card for one sporting event: hero image band, competition header with a favourite star, two team rows with optional crests, a kick-off block, a market-type pill, an odds row, and an optional "See other markets" link pill. Emits goma:outcome-select / goma:outcome-deselect (identical to every other odds button, so both betslips pick it up with no wiring), goma:event-navigate on a body tap (so a sibling <goma-game-details> follows it) alongside goma:highlight-select, goma:highlight-cta from the link pill, and goma:favorite-toggle / goma:login-required from the star. Writes goma:betslip:v3 and the events array of goma:favorites:v1.

    <goma-pros-choice>. "The Pros' Choice" promo card, promoting a single market on a single fixture: artwork strip, competition row over a fading hairline, the promoted market's name opposite the kick-off, the fixture as a pill bled flush to the card's left edge, and odds buttons labelled with outcome names (player names for a scorer market, not Home / Draw / Away). Emits goma:outcome-select / goma:outcome-deselect, goma:pros-choice-select on a body tap plus goma:event-navigate when the card carries an eventId, goma:pros-choice-cta from the link, and goma:favorite-toggle / goma:login-required from the star. The star is event-scoped on both cards: what a player is starring is the fixture in front of them, not every fixture in the same league, so it writes the events array of goma:favorites:v1, and it is hidden entirely on a card with no eventId.

    @gomagaming/sports-domain gains the presentational layer both cards compose: PromoCardShell, PromoCardHero, PromoCardCompetitionRow, PromoCardKickoffTime and PromoCardCtaLink, all host-fed and store-free. They render what they are handed and report taps, while the owning widget keeps the feed, the betslip and the favourites gate. CountryFlag also takes a new src prop that wins over the venueId CDN lookup, for hosts whose per-item flag URL does not follow the CDN naming scheme.

    <goma-highlight-card>'s "See other markets" link now emits goma:event-navigate alongside goma:highlight-cta, so it opens the event with no host code: the composition rule <goma-pros-choice> already followed. It previously reported the tap and stopped, leaving the two widgets' identical-looking links behaving differently. Enter and Space on that link no longer also activate the card shell.