Skip to content

<goma-hero-card> ​

A featured hero card for a single sporting event: fixed header (favourite star, sport + region glyph, competition name), a full-bleed hero image with optional team flag badges, and a swiping bottom panel of up to marketCount markets with a live score/clock or kickoff time. Designed for prominent placement at the top of a page or as a lead card in a GomaPulse layout.

Live demo ​

Open in the playground →: toggle Hero card in the widget rail and pick the prop source in the inspector to render a self-contained card with a hero image and swiping market strip.

Element tag ​

html
<goma-hero-card></goma-hero-card>

The card has a built-in min-height: 460px fallback on its inner wrapper, so it stays visible even inside a parent with no height of its own, but that's a safety net, not a substitute for giving it a sized container.

Installation ​

bash
pnpm add @gomagaming/hero-card vue pinia vue-router vue-i18n @vueuse/core autobahn-browser swiper

vue, pinia, vue-router, vue-i18n, @vueuse/core, autobahn-browser, and swiper are declared as peerDependencies: installed once at the app level and deduped across every @gomagaming/* widget. See Installation for the one-time .npmrc token setup.

Configuration ​

js
const el = document.querySelector('goma-hero-card')
el.config = {
  // WAMP socket for live match + market subscriptions ('subscribe' mode only)
  socketUrl: 'wss://sports-api.example.com/v2',
  socketRealm: 'www.example.com',

  // REST base URL for the sports API
  apiBaseUrl: 'https://sports-api.example.com',

  // Optional: locale propagates to bundled i18n
  locale: 'en',
}
KeyTypeRequiredDescription
socketUrlstringFor subscribe modeWAMP socket URL for the match + market subscriptions.
socketRealmstringFor subscribe modeWAMP realm.
apiBaseUrlstringYesREST base URL for the sports API.
localestringNoi18n locale. Defaults to 'en'.
favoritesApiBaseUrlstringNoFavourites 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.
themeobjectNoCSS variable overrides: see Theming.
appearanceobjectNoStructure overrides (sizes, spacing): see Appearance.
messagesobjectNoi18n overrides: see i18n / message overrides.
debugbooleanNoVerbose console logging (connection retries, config-validation warnings). Defaults to false.

In prop mode (the default) no socket is needed: the host feeds all data via JS properties. Either way the card renders nothing until it has an event (via card, hostEvent, event, or a subscribe-mode eventId).

Data sources ​

There are four ways data reaches this widget: three host-fed shapes and one live subscription. They are not alternatives to each other in the same way, so the rule has two halves.

The four intake paths, and which one wins ​

#PathSet viaShapeWins over
1cardel.cardOne shared GomaPromoCard record: the same record <goma-highlight-card> / <goma-pros-choice> take and <goma-multi-banners> takes an array of. One market.everything below
2hostEventel.hostEventOne GomaHostEvent: friendly, documented, many markets.the raw props
3Raw propsel.event / el.markets / el.outcomes / el.bettingOffersProvider-shaped store records. Advanced escape hatch, lossless.-
4Live feedel.dataSource = 'subscribe' + an event idThe {matchId}/match-odds WAMP topic.see below

Manual precedence: card > hostEvent > raw props. One ordered chain: the highest path that is set wins outright, and the ones below it are ignored while it is. Clearing the top path (el.card = null) hands control down to the next one, whatever it currently holds. The rule is "the friendlier, more curated shape beats the more provider-shaped one": the same direction the shipped hostEvent-beats-raw-props rule already ran in, so nothing that worked before changes. It also means setting a card on an element that already had a hostEvent visibly does something, instead of being a silent no-op.

The live feed is the other axis, not a fourth rung. dataSource: 'subscribe' streams into the same four store slots the manual paths write, so anything a manual path put there is a seed that the first live dump replaces. Two consequences worth knowing:

  • The subscription's event id is eventId first, then card.eventId, so a CMS record carrying artwork plus ids drives the live feed with no extra host wiring. A record's own id is a CMS slot key and is never used as an event id (it would reach the betslip and goma:event-navigate as a real-looking but wrong event).
  • In subscribe mode a card only seeds the slots it actually carries: a record with no teams and no market patches nothing, so it cannot blank a card the feed has already filled.

Recommendation. Use card when the same CMS record already feeds the promo-card family, and hostEvent when this widget is fed on its own and you want more than one market. The raw props remain fully supported: see why they are not redundant.

prop (default): host-fed ​

The host supplies all match data directly as JS properties; no WAMP connection is opened. Three shapes are accepted: prefer card or hostEvent.

card: the shared promo-card record ​

Set el.card to one GomaPromoCard record. It carries one market, so the swiper shows a single slide; everything else (teams, competition, kick-off, odds, artwork) is read straight off the record.

js
el.dataSource = 'prop'
el.card = {
  id: 'cms-slot-3',                  // CMS slot key: never used as an event id
  image: 'https://cdn.example.com/promo.jpg', // hero artwork (see below)
  eventId: '900123456789012345',     // the real event id, as a string
  sportId: 1,
  competitionName: 'Premier League',
  homeTeam: 'Arsenal',
  awayTeam: 'Chelsea',
  startTime: 1789000000000,
  isLive: false,
  marketId: '69',
  marketName: 'Match Result',
  bettingTypeId: 69,                 // mandatory in practice: see the warning below
  eventPartId: 3,
  outcomes: [
    { outcomeId: 'o1', label: 'Arsenal', odds: 2.1, bettingOfferId: 'bo1' },
    { outcomeId: 'o2', label: 'Draw', odds: 3.4, bettingOfferId: 'bo2' },
  ],
}

Notes:

  • card.image is the hero artwork when heroImage is not set; an explicit el.heroImage still wins.
  • card.defaultMarket, or the published record key card.marketId as its alias, is the market selector (the default-market row under Props / attributes). In prop mode it names the record's own single market; in subscribe mode it selects among the feed's markets.
  • Outcome keys are forgiving: label / name / outcomeName, odds / odd, outcomeId / id, bettingOfferId / offerId.
  • A bettingOfferId is never invented. An outcome without one still renders and still prices; it simply emits bettingOfferId: null, and the betslip warns rather than sending an offer id the provider never issued.
  • A record that declares a market with no bettingTypeId logs one warning: the provider rejects such a leg with "Invalid or missing Betting Type Id".
  • The auto shape is legal and useful: { id, image, eventId, sportId, defaultMarket } plus dataSource: 'subscribe', and the feed supplies the rest.

Set el.hostEvent to one GomaHostEvent object and the widget expands it internally (via normalizeHostEvent) into the same event/markets/outcomes/betting-offers slots a live feed would populate. hostEvent wins outright over the raw props below when both are set, and loses to card.

js
el.dataSource = 'prop'
el.hostEvent = {
  id: '900123456789012345',
  homeTeam: 'Arsenal',
  awayTeam: 'Chelsea',
  competition: 'Premier League',
  sportId: 1,
  startTime: 1789000000000,
  isLive: false,
  markets: [ /* … see the host-fed data guide */ ],
}
el.heroImage = 'https://cdn.example.com/hero.jpg'
el.homeFlag = 'https://cdn.example.com/flag-arsenal.png' // optional
el.awayFlag = 'https://cdn.example.com/flag-chelsea.png' // optional

heroImage / homeFlag / awayFlag are not part of the GomaHostEvent contract: they're separate, always-host-fed properties this widget adds on top (no API carries them). Supply as many markets as you like on hostEvent.markets; the widget curates them down to one per betting-type + event-part combination (preferring the entry marked mainLine: true) and caps the result to marketCount (default 5): the identical curation the subscribe-mode feed goes through.

Clear it with el.hostEvent = null to hand control back to the raw props below, whatever they currently hold. Removing the host-event attribute does not do this: like every attribute on a GomaPulse custom element, attribute removal is a silent no-op; only assigning the JS property clears it. See Host-fed data for the full field-by-field contract, including the mandatory-in-practice bettingTypeId warning.

Raw props: advanced escape hatch ​

event / markets / outcomes / bettingOffers map straight into the same store slots card and hostEvent expand into, with no normalization step: for a host that already holds data shaped like the live feed's own records. Ignored while card or hostEvent is set.

They are not redundant. normalizeHostEvent emits a fixed subset of the market/outcome shape, so three things a host may legitimately hold survive here and nowhere else:

FieldEffect
outcome.typeNameFeeds the qualifier in the outcome name ("Everton: Yes (Over)") that reaches the betslip leg and goma:outcome-select. Dropped by hostEvent.
outcome.shortTranslatedName distinct from translatedNameSame naming rule: hostEvent sets both to the one label.
outcome.priceUp / priceDownCopied onto the betslip selection. Not part of GomaHostEvent.

One more difference in the same area: hostEvent synthesizes a bettingOfferId ({outcomeId}:offer) for an outcome that has none, while this path passes through exactly what the host holds. Including nothing.

Since this release the raw path also warns, once per market, when a market carries no bettingTypeId: the same warning the hostEvent path has always logged, for the same reason (the provider rejects the leg at place-bet).

js
el.dataSource = 'prop'
el.event = { /* match object */ }
el.markets = [ /* market objects */ ]
el.outcomes = [ /* outcome objects */ ]
el.bettingOffers = [ /* offer objects with live odds */ ]
el.heroImage = 'https://cdn.example.com/hero.jpg'
el.marketCount = 5 // show the first 5 curated markets (optional, default 5)

subscribe: live WAMP ​

The widget opens a targeted, per-instance match subscription keyed by eventId: subscribeToMatch populates the event header / score / clock, and subscribeToMarketsByMarketGroupKey streams the match's full market set (the {matchId}/match-odds topic) so odds tick live. The same curation as above narrows that set to distinct main markets and shows the first marketCount of them. heroImage (and homeFlag / awayFlag) are always host-fed: no API carries them.

js
el.config = {
  socketUrl: 'wss://sports-api.example.com/v2',
  socketRealm: 'www.example.com',
  apiBaseUrl: 'https://sports-api.example.com',
  locale: 'en',
}
el.dataSource = 'subscribe'
el.eventId = '12345'
el.marketCount = 3 // optional; default 5
el.heroImage = 'https://cdn.example.com/hero.jpg'

Props / attributes ​

HTML attributeJS propertyTypeDefaultNotes
event-ideventIdstring | nullnullThe match id to render in subscribe mode. Changing it tears down the current subscription and re-registers.
market-countmarketCountnumber5Number of curated markets to show.
default-marketdefaultMarketstring | number | nullnullWhich market leads the swiper. A bettingTypeId is the recommended value; a market instance id, a displayKey and a {bettingTypeId}_{eventPartId} composite are also accepted. No match leaves the order unchanged (and warns once).
cardcardGomaPromoCard | nullnullThe shared cross-widget promo-card record. Highest-precedence intake path: wins over hostEvent and the raw props. See The four intake paths.
host-eventhostEventGomaHostEvent | nullnullMulti-market host-fed path: see Data sources. Wins outright over event/markets/outcomes/bettingOffers when set; loses to card.
eventeventobject | nullnullAdvanced escape hatch: host-supplied match object (patched into the betting store). Ignored while card or hostEvent is set.
marketsmarketsarray[]Advanced escape hatch: host-supplied market objects. Ignored while card or hostEvent is set. Warns once per market with no bettingTypeId.
outcomesoutcomesarray[]Advanced escape hatch: host-supplied outcome objects. Ignored while card or hostEvent is set.
betting-offersbettingOffersarray[]Advanced escape hatch: host-supplied betting offers with live odds. Ignored while card or hostEvent is set.
hero-imageheroImagestring''URL of the hero background image. Always host-fed. Falls back to card.image when a card is set.
home-flaghomeFlagstring''URL of the home team flag / crest image.
away-flagawayFlagstring''URL of the away team flag / crest image.
data-sourcedataSource'prop' | 'subscribe''prop'Selects the data path. 'subscribe' requires socketUrl + socketRealm in config.
autoplayautoplaybooleantrueWhether the market swiper auto-advances.
autoplay-intervalautoplayIntervalnumber5000Auto-advance interval in milliseconds.
show-favoriteshowFavoritebooleantrueWhether the favorite toggle is rendered in the header.
show-dotsshowDotsbooleantrueWhether pagination dots are shown below the swiper.
is-favoriteisFavoritebooleanfalseSeed for the favourite state. The star's source of truth is the shared goma:favorites:v1 envelope; setting this to true while signed in mirrors the match into that envelope once, so a host that already knows the player starred it can say so.
localelocalestring'en'i18n locale. Mirrored attribute.
-configobject{}JS-only; full runtime config.
-themeobject{}Sparse --goma-* token overrides (see Theming).
appearanceappearanceobject{}Structure overrides (see Appearance).

HTML-attribute forms of object props (card, host-event, event, markets, outcomes, betting-offers, theme) are parsed with JSON.parse. In practice, set these as JS properties.

Events ​

All events use { bubbles: true, composed: true }.

EventDetailWhen
ready{}Widget mounted, initial render done.
goma:outcome-select{ bettingOfferId, outcomeId, outcomeName, odds, eventId, marketId, bettingTypeId, eventPartId, line }User taps an odds button to add a selection to the betslip.
goma:outcome-deselect{ bettingOfferId, outcomeId }User taps an already-selected odds button to remove it. Emitted by the shared odds buttons on de-selection.
goma:event-navigate{ eventId, homeTeam, awayTeam, competitionName }User taps the card body (outside an odds button) to navigate to the full event details.
goma:favorite-toggle{ type: 'event', id, eventId, isFavorite }User taps the favorite toggle while logged in. The canonical favourites event, identical to the one every other star emits. The shared goma:favorites:v1 envelope is written before it fires.
goma:favorite-select{ eventId, isFavorite }Deprecated. Emitted alongside goma:favorite-toggle for one release so hosts already listening keep working. No other widget emits this name: migrate to goma:favorite-toggle.
goma:login-required{ reason: 'favorite', type: 'event', id: string }Emitted instead of the two events above when the favourite star is tapped while logged out (no resolved session). The widget does not change the favourite and writes nothing; the host should open its login flow.
goma:seo-data{ widget: 'hero-card', type: 'sports-event', items: object[] }Fired once the card's event resolves (from any intake mode), and again only when its identity changes: never on an odds tick. See SEO data for the payload contract.
goma:error{ message, code, component? }Two paths. Connection layer: the WAMP adapter (subscribe data source) 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 error banner instead. This is the one widget in the family whose banner has no Retry button (dismiss × only, see Empty / loading / error states), so a caught child error emits nothing at all, not even goma:retry.

Integrates with ​

This widget listens for nothing: it has no follow* property.

Its card-body tap emits goma:event-navigate, which <goma-game-details> follows by default (followEventNavigate, on) to swap its displayed event: a common downstream pairing with a hero card as the page's lead event.

goma:outcome-select writes selections into the shared betslip envelope goma:betslip:v4 (identity fields only, useSharedBetslipSync({ subscribeToState: false }), the same opt-out <goma-events-horizontal> uses), so any <goma-betslip-floating> / <goma-betslip-sidebar> on the page picks the selection up automatically.

The favourite star reads the shared goma:session:v1 session (via useSharedSession) to gate itself: no resolved session emits goma:login-required instead of toggling, and writes nothing. When signed in it reads and writes the shared goma:favorites:v1 envelope through the same gate every other star uses, so a hero card and an <goma-events-horizontal> card for the same match stay in agreement on the page (and across tabs). The isFavorite prop is a seed into that envelope, not a parallel source of truth. See Cross-widget wiring and Shared state for the full contracts.

Empty / loading / error states ​

  • Loading: subscribe mode only, with an eventId set and no markets yet: a skeleton layout (header outline + image placeholder + market pill skeletons + dots). prop mode never shows this state: its data is fed inline, so it resolves straight to the card or the empty state.
  • No markets: no event has resolved yet (nothing fed via hostEvent / event, and not in subscribe mode), or an event has resolved with zero curated markets to show: the same no_markets_short empty state renders either way.
  • Render error. Caught by the in-shadow error boundary: an error banner with a dismiss button, auto-clearing after 8 seconds.

Theming ​

The hero card paints seven of the shared --goma-* tokens: six from its own CSS (src/styles/card.css), plus --goma-backgroundOdds through the shared RegularOdds button. No tokens are introduced specifically for this widget. See Theming for the complete reference.

TokenSurfaceDefault
--goma-backgroundCardsCard inner fill (fallback behind the hero image)#0a0d2b
--goma-backgroundPrimaryPage background (fallback)#03061b
--goma-textPrimaryPrimary text#ffffff
--goma-highlightPrimaryAccent colour (market pill, kickoff/score, dots, focus rings)#ff7a00
--goma-favoritesFavourite star#fac125
--goma-backgroundOddsOdds button background (painted by the shared RegularOdds)#434799
--goma-separatorLineInactive dot / decorative chevrons / skeleton shimmer#2a2d60

Odds buttons are the shared RegularOdds component, which paints its own --goma-backgroundOdds surface: this widget's own CSS does not read it directly, but overriding it recolours the hero card's odds buttons.

js
el.theme = {
  backgroundCards: '#1a1a2e',
  highlightPrimary: '#ff6600',
}

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
borderWidth--goma-heroCardBorderWidth2pxWidth of the highlightPrimary ring around the card.
borderRadius--goma-heroCardRadius18pxOuter corner radius; the inner card follows at borderRadius - borderWidth.
minHeight--goma-heroCardMinHeight464pxMinimum height of the whole card, ring included. The card still grows with its content.
paddingTop--goma-heroCardPaddingTop10pxSpace above the competition header row.
paddingX--goma-heroCardPaddingX12pxSide inset of the header, its divider, the market panel, the odds row and the team flag badges.
competitionFontSize--goma-heroCardCompetitionFontSize13pxCompetition name font size.
competitionFontWeight--goma-heroCardCompetitionFontWeight700Competition name font weight.
marketPillPaddingY--goma-heroCardMarketPillPaddingY2pxMarket pill top and bottom padding (inside its 1px ring).
marketPillPaddingX--goma-heroCardMarketPillPaddingX10pxMarket pill side padding.
marketPillLineHeight--goma-heroCardMarketPillLineHeightinheritedMarket pill line height.
oddsGap--goma-heroCardOddsGap8pxGap between the odds buttons.
js
el.appearance = { borderWidth: '1px', borderRadius: '8px', paddingX: '10px', oddsGap: '10px' }

i18n / message overrides ​

The widget bundles strings via @gomagaming/core's shared message catalogue. Pass config.messages to override per-locale phrases:

js
el.config = {
  locale: 'en',
  messages: {
    en: {
      no_markets_short: 'No markets',
    },
  },
}

Keys used by the widget's own components:

  • no_markets_short ("No markets at the moment") empty state message.
  • add_favorite ("Add to favorites") favorite star aria-label when not favourited.
  • remove_favorite ("Remove from favorites") favorite star aria-label when favourited.

Embedding examples ​

Vanilla HTML ​

html
<goma-hero-card id="hc"></goma-hero-card>
<script type="module">
  import '@gomagaming/hero-card'

  const el = document.getElementById('hc')
  el.config = { locale: 'en' }
  el.heroImage = 'https://cdn.example.com/match-hero.jpg'
  el.hostEvent = {
    id: '12345',
    homeTeam: 'Arsenal',
    awayTeam: 'Chelsea',
    competition: 'Premier League',
    markets: [ /* … see the host-fed data guide */ ],
  }
</script>

React ​

jsx
import '@gomagaming/hero-card'

function HeroMatchCard({ hostEvent, heroImage }) {
  return (
    <goma-hero-card
      ref={(el) => {
        if (!el) return
        el.config = { locale: 'en' }
        el.heroImage = heroImage
        el.hostEvent = hostEvent
      }}
    />
  )
}

Vue ​

vue
<script setup>
import '@gomagaming/hero-card'
import { ref, onMounted, watch } from 'vue'

const props = defineProps({
  hostEvent: Object,
  heroImage: String,
})
const el = ref(null)

onMounted(() => {
  el.value.config = { locale: 'en' }
  el.value.heroImage = props.heroImage
  el.value.hostEvent = props.hostEvent
})

watch(() => props.hostEvent, (v) => { if (el.value) el.value.hostEvent = v })
</script>

<template>
  <goma-hero-card
    ref="el"
    @goma:event-navigate="$emit('navigate', $event.detail)"
    @goma:favorite-toggle="$emit('favorite', $event.detail)"
  />
</template>

Accessibility ​

  • The card-body tap target is role="button" with tabindex="0", an aria-label naming the two teams, and responds to Enter / Space.
  • The favorite toggle is a <button> with aria-pressed reflecting isFavorite and an aria-label from the add_favorite / remove_favorite keys above.
  • Odds buttons (the shared RegularOdds component) carry role="button", aria-pressed for selected state, and aria-disabled when unavailable.
  • The pagination dots use role="tablist" / role="tab" with aria-selected on the active dot.
  • The market swiper's autoplay pauses on mouse hover (pauseOnMouseEnter).

What's deferred ​

These are recognised next-version surfaces, not omissions:

  • rpc data source: one-shot WAMP fetch for snapshot data without a persistent subscription.
  • Streams / stats overlay: in-card live stats or animation overlays (e.g. a football pitch map) are out of scope for the initial release.

Integration Caveats ​

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

  • Sizing: this widget ships a min-height: 460px CSS fallback for unsized hosts. These docs did not mention it before; it is documented now.

  • 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.6
  • Read the live score in any feed language. The whole-match score row is now found by the part id of the match, and tennis sets and games by their scoring unit, not by the translated part name. In the game cards, <goma-game-details>, <goma-hero-card> and <goma-multi-banners>, a match on a language with no known part name shows its score. In the game cards and <goma-game-details>, live tennis on a Spanish feed also shows the current game points in place of the sets won. A match with no whole-match row, such as e-Tennis, shows the broadest part still running. Spanish set, game, quarter and inning names now fill the part columns.

    The live game card and the <goma-game-details> header now read one shared score, so they show the same thing. For a live match the feed sends no score for, they show the last score seen for that match. With none, they draw no score box, at any point in the match, and show the current part. A real 0 0 still draws. A live <goma-multi-banners> sport card with no score now shows none, where it showed 0 - 0. A host that feeds events with no homeScore or awayScore gets no score box in place of 0 0: send both as 0 to keep it. A set sport with no whole-match row now shows the sets won on the card, not the points of the running set. Live cards now show the feed's current part, such as 7th Game (2nd Set), where they showed the status, such as In Progress. In <goma-game-details>, goma:game-ended now carries homeScore and awayScore for a match with no whole-match row, such as e-Tennis, where it carried null.

0.37.4
    • <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
  • <goma-hero-card> gains an appearance object (structure only): borderWidth, borderRadius, minHeight, paddingTop, paddingX, competitionFontSize, competitionFontWeight, marketPillPaddingY, marketPillPaddingX, marketPillLineHeight and oddsGap. <goma-sports-navigation-horizontal> gains one too: itemWidth, labelFontSize, labelFontWeight and labelLineHeight. Unset, every key keeps today's value, so the default look of both widgets is unchanged. @gomagaming/core is bumped for the 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.

  • Stop the game-card composable from setting the host page's colour scheme

    Rendering a game card changed the page it was embedded in. useGameCardsTallOdds called VueUse's useDark(), and that helper's defaults are selector: 'html', attribute: 'class' and storageKey: 'vueuse-color-scheme', so the widget wrote a class onto the host document's <html> element and a key into the host's localStorage, neither of which is a widget's to touch.

    The effect was worst for a host that manages its own dark mode through a class on <html>, which is the common Tailwind arrangement: the widget resolved the visitor's OS preference and wrote its own answer over the host's, so a deliberately dark page could turn light because someone's laptop was set that way.

    Nothing consumed the value. The composable returned it as isDark and no component, template or composable in the suite ever read it, so the call was pure side effect. It is gone, along with the last @vueuse/core import in @gomagaming/sports-domain.

    <goma-favorites>, <goma-game-details> and <goma-hero-card> are the widgets that pull this composable in. Their own appearance is unchanged: every widget already carries its dark treatment inside its own shadow root and never depended on the class being written outside it.

  • Match dates read day-first in English, so one match is no longer written two different ways across the suite.

    An event card writes the kick-off as a compact 11/10; <goma-game-details>'s header, <goma-hero-card>, <goma-multi-banners> and <goma-search>'s result rows write it in words through toLocaleDateString. With locale: 'en' (the bare tag, which Intl resolves to American ordering) that long form came out as October 11 next to the card's 11/10. English was the only shipped locale that disagreed with its own cards (fr and pt are already day-first, and the shared time formatter has always been pinned to en-GB), so the long form now resolves a bare en to en-GB. A host that explicitly configures en-US keeps American ordering.

    Two surfaces build their own Intl.DateTimeFormat rather than going through the shared formatters, and both showed the same inversion for the same match: the My Bets card's leg date (which also printed 07 October where the cards print 7 October, and sat directly under a hand-built dd/mm/yyyy "placed" line) and the live-chat bet modal's per-leg date. Both now resolve the locale the same way.

    Every package listed here renders a date that changes, which is why the note reaches all of their pages rather than only the two whose source moved: including the two betslips and <goma-messenger>, which render the same leg date through the <goma-my-bets> widget they embed.

    Not covered, and deliberately: the missions surfaces format challenge dates with the browser's locale rather than the widget's, and, in the same file, render the same instant as a deliberately locale-independent DD/MM/YY two functions below, so one view contradicts itself regardless of locale. That is a different defect from this one and wants its own ticket.

  • Live cards keep their odds in step with the event page through a re-price. The provider models a re-price as a new betting-offer id rather than a change to the old one, and two things in the card's path mishandled that pair:

    • detachBettingOfferFromOutcome keyed on the OUTCOME rather than on the offer, so a re-price arriving as CREATE(new) + DELETE(old) (an order the provider is free to use) made the DELETE tear off the offer the CREATE had just attached. The card was left with no price until the next full dump. Order-dependent, which is why it read as the card being "sometimes slower, sometimes faster". It now takes the offer id and ignores a delete whose offer has already been replaced.
    • attachBettingOfferToOutcome re-attached whatever offer an update named, so a close-out on the offer a re-price had just replaced put the retired price back on the card while the event page (keyed on the offer id) stayed on the new one. A refresh now lands only on an outcome already holding that same offer. That also covers the case where the outcome holds nothing: a retired offer is never evicted from the pool, so without it a withdrawal followed by any late tick repainted a dead price as tappable, and a bet placed on it would be rejected by the provider.
    • bettingOfferId is re-pointed alongside the object it names, so the two cannot disagree. This is invariant hygiene rather than a visible fix: no surface resolves a price through that field on a live feed today.

    Separately, BETTING_OFFER records now reach both store mirrors whichever WAMP topic carried them: the shared pool the cards read and the singular game slot <goma-game-details> reads. create previously wrote exactly one of the two and never the other. The cross-writes are non-creating: an offer for an outcome a mirror does not already hold is left alone, so the event page's markets never seed the pool the cards read.

    Affects every widget that renders odds from a live feed.

  • 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.

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.

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.1
  • The hero card's header gains the divider the design shows beneath it, and its market label is set in the normal text colour rather than the accent: the ring around it keeps the accent.

    Its country flag now uses the same CountryFlag component every other card uses, resolving against the flag CDN with a globe fallback. It had been looking up a bundled SVG by ISO code with no fallback, so a venue with no matching asset rendered a broken image.

0.30.0
  • Favourite stars now animate: favouriting fills the star from its base to its tip, and un-favouriting reverses the same wipe. The star on a <goma-multi-banners> sport card is now an actual control: it was drawn as decoration, so tapping it opened the event instead of favouriting it. The favourites empty state gains its illustration and its final copy.

    Three competition surfaces now switch geometry at the 768px breakpoint instead of painting one fixed size at every width: the collapsible competition top bar and the drill-in detail header grow on desktop, and the region accordion shrinks on mobile. Where its oversized title had been truncating region names that now fit.

  • The hero card's border is now the solid highlightPrimary accent rather than a two-stop gradient, so --goma-highlightGradientStart / --goma-highlightGradientEnd no longer affect this widget (they still drive the featured rows in <goma-top-competitions> and the missions progress bar). Kick-off is split into a date above the time instead of one combined line, team names step up to 16px, and the market pill drops to 10px. The pagination lines now fill in step with the autoplay countdown, so the row doubles as a progress indicator, and the market panel holds one height across a slide change instead of resizing mid-transition.

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.

  • Corrected the <goma-favorites> and <goma-hero-card> documentation pages: no source change.

    <goma-favorites>'s Props / attributes table showed debug backed by a debug HTML attribute; only the JS property (el.debug = true) actually works, so the row now shows it as JS-property-only. Both pages listed messages as a documented prop; messages is only reachable through el.config = { messages } (no el.messages accessor exists), so the phantom prop row has been removed: the config-key documentation in each page's Configuration section was already correct and is unchanged.

0.28.0
  • Event-contract audit: six fixes across shipped widgets. Two of them change behaviour for existing embeds: read the last two entries before upgrading.

    The outright card's competition star now has a login gate: the star on OutrightRegularOdds (shown for _type === 'TOURNAMENT' events, and so in <goma-events-horizontal>, <goma-favorites> and <goma-competitions-list>) was the only star in the family that skipped guardedFavoriteToggle. Tapped while signed out it wrote and emitted anyway, contradicting the promise in the cross-widget wiring guide that a signed-out star does not write. It now emits goma:login-required when signed out, and writes the shared goma:favorites:v1 envelope (instead of reading a widget-local store slice) when signed in, so it agrees with every other competition star on the page. Its goma:favorite-toggle payload gains type and id and drops eventId, which held a competition id: a host resolving that value as an event id was looking up the wrong entity. type: 'competition' + id carry the same information under honest names, and match what the other competition stars have always emitted.

    <goma-filters-settings> now moves the feed. Applying a time window emitted only goma:filters-apply, which no feed widget listens for, so the mobile filter funnel left every sibling feed exactly where it was, while the identical window picked in <goma-time-filter> moved it. applyFilters() now also emits goma:time-select ({ timeValue, timeLabel, previousTimeValue }, the same payload <goma-time-filter> emits) when the window actually changed, immediately before the unchanged goma:filters-apply summary event.

    <goma-game-details> gains eventNavigateSelector (event-navigate-selector, default ''). goma:event-navigate has about a dozen emitters and followEventNavigate is on by default, so two game-details panels on one page both jumped to every card tap anywhere. The new prop is the same CSS-selector scope guard <goma-events-horizontal> and the picker followers already carry, matched against the event's retargeted host. Empty (the default) keeps today's follow-everything behaviour.

    goma:outcome-deselect no longer puts an outcome id in bettingOfferId. Removing a row from the betslip panel emitted { bettingOfferId: <outcomeId> }, while the card-side odds button sends a real offer id in that field: a host deduping on detail.bettingOfferId got mismatched keys from the two surfaces. The panel now emits { bettingOfferId: null, outcomeId }, the card emits bettingOfferId: null rather than undefined when there is no offer, and the GomaEventMap type widens to { bettingOfferId?: string | null; outcomeId: string }. Dedupe on outcomeId. Both surfaces always carry it.

    Behaviour change: <goma-hero-card>'s favourite star now writes shared state. The star was local ref state emitting goma:favorite-select, an event name no other widget uses, so a hero card and an events-horizontal card for the same match disagreed on the same page. It now goes through guardedFavoriteToggle and the shared goma:favorites:v1 envelope, and emits the canonical goma:favorite-toggle. If your page mounts <goma-favorites> or otherwise persists that envelope, a tap here is now persisted where it previously was not. The isFavorite prop becomes a seed (mirrored into the envelope once, while signed in) rather than a parallel source of truth. goma:favorite-select is deprecated: still emitted alongside goma:favorite-toggle for one release so existing listeners keep working; migrate to goma:favorite-toggle, it will be removed next.

    Behaviour change: <goma-top-competitions-pills> now follows the sport picker by default. followSportPicker flips from false to true, matching <goma-region-filter>, <goma-competitions-list> and <goma-events-horizontal>, so swapping one of those for this widget no longer silently loses sport-following. An existing embed that mounts this widget alongside a sports picker and relied on the pill row staying put will now re-fetch on every sport change. Set follow-sport-picker="false" (or el.followSportPicker = false) to keep the old behaviour.

  • <goma-events-horizontal>, <goma-favorites> and <goma-competitions-list>: add a size prop (size attribute, 'default' | 'large', default 'default') that switches the whole event rail between the baseline card geometry and an enlarged one, and bring both geometries back onto the design's heights.

    Behaviour change. The default card is 8px shorter. The game card had drifted 8px taller than the design and has been brought back onto it: the main card is now 156px tall at size="default" (was 164px) and 188px at size="large" (was 196px). This is not opt-in and there is no flag to keep the old height: it changes the default rendering of every shipped embed the moment it is upgraded. A host will see each event card, loading skeleton, empty-state block and See All tile lose 8px of height, so an events rail gets 8px shorter per row and any surrounding layout that was sized around the old height (a fixed-height container, a hard-coded calc() offset, a screenshot-diff baseline) shifts by that much. Nothing is clipped (the content stack still fits inside the card at both sizes) and the design is the authority on the number.

    Behaviour change: every game card and every odds button is restyled. The card's internal geometry had drifted off the design frame in thirteen measured places, and all thirteen are corrected here. This is not opt-in, there is no flag to keep the old look, and it applies at both size variants: the design's size=default and size=large frames carry identical card chrome, so each value below is the same number in both.

    What a host actually sees on the game card:

    wasnow
    Odds row height48px40px
    Odds button padding8px sides / 4px top+bottom8px all round
    Odds button corner6px4px
    Card padding, top / bottom4px / 12px8px / 10px
    Breadcrumb row height24px16px
    Competition name weight300500
    Team name weight600700
    Team name line height20px16px
    Gap between the two team rows4px6px (default only)
    Market badge height14px16px
    Market badge side padding12px8px

    The visible result is a tighter, more deliberate card: the team block reads as one unit instead of three loosely spaced lines, the competition name is legible instead of hairline, and the odds row is a 40px band of 4px-cornered buttons rather than a 48px band of 6px-cornered ones. Nothing is clipped or truncated that was not clipped before, and no card's content reaches its painted edge at either size: the shortest remaining bottom gutter across every card type, size and state is 10px.

    The odds button is shared, so this restyle reaches further than the event feeds. RegularOdds is the only odds button in the product. Its 8px padding and 4px corner therefore also change the odds buttons inside <goma-game-details>, <goma-hero-card> and <goma-multi-banners>, which render it directly. Those widgets already sized the button at 40px, so only its padding and corner move; their own layouts are untouched. Any host screenshot baseline that includes an odds button anywhere will shift.

    The card corner stays on the 8px radius token. The frame reports 8.9376px, but that is the only non-integer value in the whole frame, the paddings (8/12/10/12), the row heights (16/40) and the odds button's own 4px corner are all exact integers, so it reads as a corner artefact of a resized instance rather than a designed value. A sub-pixel radius difference is not visible, and honouring it would mean carrying a matching arbitrary radius on the gradient-border wrapper of every card component to stop the border mis-rounding.

    The outright card is left on its own geometry. It is a different component with a different box (145 / 177px), no team rows, no market badge and no odds grid (it renders a single full-width action button, not RegularOdds) and none of the thirteen values above were measured against an outright frame. Changing it would be a guess, so it keeps the padding, breadcrumb and corner it has today.

    Where the 8px of drift came from. The odds row being 48px instead of 40px is the origin of the earlier 164-vs-156 card height: the card had been padded to fit oversized buttons. When the card was brought back to the design's 156px while the buttons stayed at 48px, the pre-live card's content ran the full height of the box and its bottom gutter went to zero. Correcting the row height hands that back: the pre-live card now carries a 15px gutter at default.

    Three follow-on adjustments come with it:

    • The secondary market card's inner column follows the card height by the same relationship as before, re-derived from the new padding: cardHeight − 46px (top padding 8 + bottom padding 10 + 2 borders + a 26px breadcrumb budget), so 112px → 110px at default and 144px → 142px at large. Its odds row is the last child of a fixed-height justify-between column, so the row keeps its bottom edge and simply becomes 8px shorter.
    • The suspended / no-odds block takes the odds row's new 40px height and its 4px corner, so the card does not change shape when a market closes. The pre-live card's copy also takes the same top margin as the odds grid it replaces, matching what the live card has always done.
    • The outright card keeps its own 145 / 177px geometry. It was already a separate, shorter box; the change narrows the gap to the main card rather than widening it.

    'large' is the design system's size=large variant of the game card and the secondary market card:

    • Main game card: team names 16px → 18px, the internal vertical gaps opened up (the design's 7px → 12px container gap and 6px → 18px gap between the two team rows), and the card 32px taller.
    • Secondary market card: market title 14px → 16px, team names 10px → 14px, and the same 32px height step so a mixed rail stays level.
    • Outright card and the See All tile take the same 32px step, which is what keeps a rail carrying several card types from going ragged. The design system has no dedicated large outright card; this is the consistent extrapolation.

    The loading skeleton and the feed's empty-state block take the same 32px step, so the placeholder a feed paints while its first dump is in flight is exactly as tall as the card that replaces it. Without that, a large feed rendered 156px skeletons and jumped 32px on every load: most visible on a teaser that reloads whenever a sport picker changes. Skeleton height and card height are now pinned to each other by tests in all three widgets.

    The loading skeleton mirrors the card's new chrome as well as its height (the same 8px/10px card padding and the same 40px, 4px-cornered odds placeholders) so the placeholder a feed paints while its first dump is in flight is the same shape as the card that replaces it, not just the same height. Tests pin both.

    size is set on the feed widget and threads down to the cards, the same shape defaultMarket and oddsFilter already use: the cards themselves gain a size prop and stay presentational. All three widgets render the event rail from byte-identical copies of the same component, so the prop lands on all three together; a root test now asserts those three copies stay identical, since a prop reaching one copy and not the others is silent when it happens.

    @gomagaming/core is republished because it compiles the single stylesheet adopted into every widget's shadow root from the workspace's sources: both card geometries' utilities live in that bundle, not in the widget packages.

  • <goma-hero-card>: two additive intake improvements, plus a warning that was missing on one path.

    defaultMarket: the card had marketCount (how many markets to show) but no way to say which market to lead with. defaultMarket takes a bettingTypeId (marketId is accepted as a synonym) and selects it through the shared market selector the promo cards use. With no defaultMarket supplied the rendering is unchanged, so existing embeds are unaffected.

    A card prop taking the same promo-card record <goma-highlight-card>, <goma-pros-choice> and <goma-multi-banners> accept. One CMS record type now feeds every card widget in the family: the banner carousel takes an array of them, the single-card widgets take one. This is a fourth intake path alongside hostEvent, the raw event/markets/outcomes/bettingOffers props and eventId + dataSource: 'subscribe'; all three keep working exactly as before, and the precedence between them is documented on the widget page.

    Missing warning: the hostEvent path warned when a host-fed market carried no bettingTypeId, but the raw-props path did not. A betslip leg without it is rejected by the provider with "Invalid or missing Betting Type Id", so the raw path now warns too.

0.26.0
  • Stop publishing source maps and package source.

    dist/*.js.map was emitted by the shared Vite preset and swept into every tarball by files: ["dist/", "src/"], with the original source inlined in sourcesContent. src/ shipped the same source again, in the clear. Both are one URL away from a browser once a package is served from a CDN, so the preset now builds with sourcemap: false and files no longer carries src/.

    @gomagaming/core keeps the two paths its export map actually points at: src/styles/main.css and src/types/index.d.ts. Its tarball drops from 1741 files to 576, which also brings it back under the 900-file limit a per-request unpacker can handle.

    Deep imports of @gomagaming/<pkg>/src/* from an installed package no longer resolve. Nothing documented used them; imports inside the monorepo are unaffected.

0.25.0
  • Host-fed event contract: accept normalized host events in hero-card and sports-domain mapper

    Adds a normalizeHostEvent mapper in sports-domain that expands a provider-agnostic host event into the betting-store slots that the match-odds feed writes, enabling hosts without a sports-API connection to drive cards directly.

    Hero-card now accepts a hostEvent property (with normalized shape) as an alternative to raw provider-specific props. When both are set, hostEvent takes precedence.

    Console warnings emitted by the mapper and by core's config validation are now prefixed [goma], matching every other warning the packages emit.

0.19.0
  • Fix place-bet rejected with "Invalid or missing Betting Type Id on placed bet"

    Every leg was serialised with bettingTypeId: null, so the provider rejected the whole slip. The odds card captured the field correctly: it was lost in transit to the betslip widget.

    Tapping odds does two things in the same tick: it writes a full selection to the source widget's own store, then dispatches goma:outcome-select. That event is synchronous, so a betslip widget's window listener inserted its row from the event detail alone, which carried no bettingTypeId, a tick before the complete shared-storage snapshot arrived. The inbound sync then skipped the row because its outcomeId was already present, so the partial first writer won permanently.

    Fixed on both sides:

    • goma:outcome-select now carries bettingTypeId and eventPartId (it already carried outcomeId, previously undocumented). Additive: existing listeners are unaffected.
    • The shared-betslip inbound sync backfills identity fields that are missing from the local copy, repairing any selection that entered through a lossier path. Absent, undefined and null all count as missing, so the repair does not depend on how a producer spells "unknown"; a null on the wire never overwrites an id already held locally. Live fields (odd / isAvailable / isLive) stay owned by each widget's own feed and are never backfilled.
    • When a goma:outcome-select detail arrives without a bettingTypeId (a host driving the betslip from its own UI, where no richer snapshot exists to repair the row) the betslip logs a console.warn naming the outcome, once per outcome. The first symptom used to be the provider's HTTP 409, which names neither the leg nor the missing field.
    • <goma-multi-banners> inline sport banners forward bettingTypeId / eventPartId from the banner to the odds button. The eventId path already resolved them from the feed; inline banners had no way to supply them, so their legs were rejected at place-bet.

    Affects every odds button in every widget: they all render the shared RegularOdds component.

0.15.0
  • Favourite stars are now login-gated. When a favourite star is tapped while no session is present, the widget does not write the favourite; instead, it emits a new goma:login-required event ({ reason: 'favorite', type: 'competition' | 'event', id: string }) so the host can open its login flow. A shared guardedFavoriteToggle helper and a useSharedSession().isLoggedIn signal back the gate. Applies to the shared game-card star (events-horizontal / favorites), the events-horizontal top-bar competition star, the favorites section star, and hero-card's host-owned favourite toggle.
0.13.0
  • Add <goma-hero-card>: a featured hero card for a single sporting event: fixed header (teams, score/clock, favorite), full-image overlay, and a swiping bottom panel of the event's markets. Supports prop (host-fed) and subscribe (live single-match WAMP via subscribeToMatch + subscribeToMarketsByMarketGroupKey('allMarkets')) data sources; the widget curates the match's distinct main markets and shows the first marketCount. Emits goma:outcome-select, goma:outcome-deselect, goma:event-navigate, and goma:favorite-select. Loading skeleton in subscribe mode; hero image is always host-fed.