<goma-events-horizontal>
Horizontal swipeable row of event cards: one main game card per event followed by additional market cards. Fully themeable, isolated Web Component with live score support, odds buttons, and betslip integration. This is GomaPulse's reference widget: its patterns (element class, App.vue wrapper, theming, topic subscription, custom events) are the blueprint the other shipped widgets follow.
Live demo
Open in the playground →: toggle Events horizontal in the widget rail, then drive props, theme, and the mock / subscribe data source from the inspector's Form / JSON / Snippet tabs. Use Share setup to capture a ?s=… URL for a support ticket or repro.
Element tag
html
<goma-events-horizontal></goma-events-horizontal>Installation
@gomagaming/events-horizontal is a scoped private package. See Installation for the one-time .npmrc setup required to consume it.
bash
npm install @gomagaming/events-horizontalYou also need the shared peer dependencies installed at your app level:
bash
npm install vue pinia vue-router vue-i18n @vueuse/core autobahn-browser swipervue, pinia, vue-router, vue-i18n, @vueuse/core, autobahn-browser, and swiper are declared as peerDependencies so a consumer with multiple @gomagaming/* widgets gets exactly one copy of each.
Configuration
Assign a config object to the element's config property:
js
const el = document.getElementById('sb')
el.config = {
socketUrl: 'wss://api-ndwstage.everymatrix.com/v2',
socketRealm: 'www.yes.dk',
apiBaseUrl: 'https://api-ndwstage.everymatrix.com',
bettingApiBaseUrl: 'https://sports-api.example.com',
ucsOperatorId: '2838',
locale: 'da',
iconBaseUrl: '/dist',
}| Key | Type | Required | Description |
|---|---|---|---|
socketUrl | string | Yes (for live) | WAMP socket URL used for topic subscriptions. |
socketRealm | string | Yes (for live) | WAMP realm. |
apiBaseUrl | string | Yes | REST base URL for the sports API. |
bettingApiBaseUrl | string | No | REST base URL for betting offers. |
ucsOperatorId | string | Yes | Operator ID. |
favoritesApiBaseUrl | string | No | Favourites REST base URL (EM user-data-service). When set (here or via the favoritesApiBaseUrl prop), star toggles on this widget's cards persist to the server via the tab-level favourites sync: even on a page with no <goma-favorites> mounted. Requires a resolved player session (another widget, e.g. <goma-favorites> or the betslip, commits it) and ucsOperatorId. Omit to keep favourites cache-only (the default). |
locale | string | No | i18n locale (e.g. da, en, fr). Defaults to 'en'. |
iconBaseUrl | string | No | Base URL where icon SVGs are served from. |
theme | object | No | CSS variable overrides: see Theming. |
messages | object | No | i18n overrides per locale: see i18n / message overrides. |
clientId | string | No | Client identifier. |
bettingApiUrl | string | No | Legacy betting API URL (prefer bettingApiBaseUrl). |
topicPrefix | string | No | WAMP topic prefix for the matches-aggregator feed. Used when building subscription URLs and when parsing or rewriting consumer-supplied topic strings (followSportPicker and friends). Override only when the backend exposes a renamed namespace. A trailing slash is added if missing. Defaults to 'custom-matches-aggregator/'. |
sportsNamespace | string | No | Root WAMP namespace shared by every sports RPC and subscription URL. RPCs become {sportsNamespace}#{operation} (e.g. /sports#disciplines); subscriptions become {sportsNamespace}/{ucsOperatorId}/{lang}/{suffix}. Override only when the backend exposes a renamed root. A trailing slash is stripped. Defaults to '/sports'. |
network | object | No | Network policy for connection + retry. Sub-fields default to historical values and may be omitted individually: connectTimeoutMs (default 15000), rpcTimeoutMs (default 15000), resumeReconnectAfterMs (default 20000), retry: { maxAttempts: 5, baseMs: 1000, capMs: 30000 }. Exponential backoff is baseMs * 2^(attempt-1), capped at capMs. See Coming back from sleep for resumeReconnectAfterMs. |
debug | boolean | No | Surfaces verbose console logging: advisory config-validation warnings (missing apiBaseUrl, unknown keys), the "no socket configured" skip notice, network online/offline transitions, per-attempt connection-retry messages, per-attempt connect failures, and every follow* picker's topic rewrite. Production-fatal errors (Vue render errors, terminal failure after retry budget exhausted, invalid attribute values) log unconditionally. Defaults to false. |
Data sources
Choose one: the widget doesn't combine them.
Regardless of source, an event that has reached a terminal state (ended, cancelled, walkover, abandoned, retired) is never rendered. Providers delete an ended event's betting offers but can keep the event itself in the feed for a while, which would otherwise show a card with - for every odd that opens a details page with nothing left to bet on. Interrupted matches are still rendered: they can resume.
A card that is rendered but has nothing to price (the sport has no main market configured, or the provider withdrew the offers while keeping the event in the feed) shows the Suspended label across the odds row. It used to show a bare -, which read as a broken card rather than a state; the two are the same thing to a punter (no bet here for now) and only one of them carries a label, so the placeholder no longer has a render path. A market the provider explicitly closed has always shown the same label.
Topic subscription
The widget subscribes to a WAMP topic and renders whatever events the feed pushes:
js
el.topic = 'custom-matches-aggregator/1/all/all/all/POPULAR/LIVE/10/5'The topic format (after the configurable prefix) is {sportId}/{locationId}/{tournamentId}/{hoursInterval}/{sortEventsBy}/{liveStatus}/{eventLimit}/{mainMarketsLimit}. Assigning a new topic rebinds the subscription in place: no reconnect. Set pageSize to a positive number to grow segment 7 (eventLimit) a page at a time via a Load more button; see Pagination below.
What a scope change does to the list
When the topic's scope changes (a different sport, region, competition, time window or live status, whether a sibling picker rewrote it or the host assigned it) the widget drops the events it is showing and paints the loading state until the new feed answers. Re-subscribing does not clear the store on its own, and the previous scope's events would otherwise stay on screen until the next dump replaced them: a slow, empty or unanswered feed left another sport's matches presented as this one's.
Two things are deliberately not treated as a scope change:
- a page-size bump (the widget's own "Load more", or the host rewriting the
eventLimitsegment directly) is the same feed asking for more of itself, and clearing there would reintroduce the flicker that rewrite exists to avoid; - an embed whose list the host owns. As long as the store still holds the array the host assigned to
events, a topic change leaves it alone, across any number of changes and whether it was assigned before or after the element connected. Once a feed dump has replaced that array, the feed owns the list and a scope change clears like any other.
Direct events
Bypass the socket and feed events as a plain array. This path goes through mapGomaEvents (packages/sports-domain/src/mappers/storeMapper.js). A different, older field vocabulary from the GomaHostEvent contract used by <goma-hero-card> / <goma-multi-banners> (see Host-fed data): field names here are homeTeam / awayTeam (not homeShortParticipantName), competitionName (not competition), homeScore / awayScore (not a score object), and currentPeriod / elapsedMinutes (not a clock object). The mapper also has no bettingTypeId / eventPartId support on markets: every market's bettingTypeId is set to the market's own id, so a place-bet leg built from a host-fed event here will not carry a provider-valid betting type.
js
el.events = [{
id: 'e1',
homeTeam: 'Aston Villa',
awayTeam: 'Nottingham Forest',
competitionId: 't1',
competitionName: 'Premier League',
countryCode: 'GB',
sportId: 1,
isLive: true,
startTime: Date.now() + 3600_000,
markets: [{
id: 'm1',
name: 'Match Result',
isAvailable: true,
outcomes: [
{ id: 'm1-o1', name: 'Home', odds: 1.5, isAvailable: true },
{ id: 'm1-o2', name: 'Away', odds: 2.5, isAvailable: true },
],
}],
}]An explicit el.events = [] is a valid answer: the widget treats it as "the host has fed us, and there is nothing" rather than "not fed yet"; see Empty / loading / error states.
A host marks an event terminal (see Data sources above) by setting its statusId: '3' ended, '5' cancelled, '6' walkover, '7' abandoned, '8' retired. An event with no statusId is always rendered.
Pagination (Load more)
By default the widget renders exactly the events its topic asks for: the 7th topic segment (eventLimit) is the page size, and it never changes. Set pageSize to a positive number to get a Load more button under the list:
js
el.topic = 'custom-matches-aggregator/1/all/all/all/POPULAR/LIVE/12/5'
el.pageSize = 12Each click rewrites the topic's eventLimit segment upward (12 → 24 → 36 …) and the subscription rebinds in place: the cards already on screen stay put while the larger set arrives.
The button hides itself when the feed runs out. Because the feed carries no total, "run out" is inferred from a short page: ask for 36, get 30, and that is everything. One click at the end of the list therefore costs a round-trip that adds nothing: that is the price of not having a count.
The page resets to the topic's original eventLimit whenever any other segment changes, so switching sport, region, competition or time window starts the new feed at page one.
Assigning topic yourself always wins. Any topic the widget did not write itself (including one that changes only the eventLimit segment) is treated as authoritative: whatever is in segment 7 becomes the new base, the page counter re-seeds from it, and a previous "feed exhausted" verdict is cleared. So a host is free to widen the page directly without the button subsequently asking for fewer events than the host set.
If a page is requested and no dump ever arrives (a socket that drops mid-click, a provider that goes quiet) the spinner clears itself after 55 seconds (long enough for the connection layer to ask again) so the button becomes clickable again. A timeout is not treated as end-of-list: the button stays available rather than hiding.
Requires a topic. In direct-events mode (el.events = [...]) the host owns the whole list, so no button renders regardless of pageSize.
Interaction with oddsFilter: the button's visibility and exhaustion are computed off the unfiltered feed count, not the list rendered after oddsFilter is applied client-side. This is deliberate (it's the only way a filtered user can keep paging deeper into the feed to find more matches) but it means that with a filter active and nothing on the current page, you can see the empty-state card with a Load more button underneath it.
Props / attributes
| Property | Attribute | Default | Description |
|---|---|---|---|
events | events (JSON) | [] | Direct-events array: see Data sources. |
topic | topic | '' | WAMP subscription topic: see Data sources. |
maxAdditionalMarkets | max-additional-markets | 5 | Max number of market cards rendered after the main game card. |
spaceBetween | space-between | 12 | Pixel gap between slides. |
showSeeAllButton | show-see-all-button | true | Whether to append a "See all" card when there are more markets. |
showFades | show-fades | true | Render the edge fades on each event's market rail. The gradient fades to --goma-backgroundSecondary, so set false when the host paints a different surface behind the widget: otherwise the fade reads as a smear rather than a hint that the rail scrolls. |
size | size | 'default' | Card size variant for the event cards. 'default' is the design's baseline geometry: 160px-tall cards with 16px team names. 'large' opts into the enlarged card: roomier internal spacing, 18px team names on the main card, 16px market titles and 14px team names on the additional-market cards, and a 190px-tall rail. The loading skeletons and the empty-state block take the same height, so a large feed never reflows when its first dump lands. The size prop itself is additive and opt-in, but the 'default' height is not: it moved from 164px to the design's 156px, and then to 160px for the reason given below. Every existing embed follows it with no way to pin an older one. The card's internal geometry was corrected onto the design in the same change and is likewise not opt-in: the odds row is 40px tall instead of 48px, the odds buttons carry 8px of padding on a 4px corner instead of 6px, the card is padded 8px/10px top/bottom instead of 4px/12px, the breadcrumb row is 16px instead of 24px, team names are weight 700 on a 16px line instead of weight 600 on a 20px line, the competition name is weight 500 instead of 300, and the market badge is 16px tall with 8px side padding instead of 14px with 12px. The two heights are EXACT, never minimums: a rail must never show cards of differing heights, so the "See all" tile, the loading skeleton and the empty state all carry the same number. They read 160px and 190px rather than the design frames' 156px and 188px because those frames set two 16px/700 team names in 16px line boxes: a line box equal to its font shears the ascenders on the web once the names are truncated, so the boxes are sized to their ink instead and design raised the card by the difference. |
layout | layout | 'rail' | Row layout for each event. 'rail' is the horizontally scrolling market rail (main card, additional market cards, optional See All tile). 'columns' renders the main card and the first additional market side by side, each filling half the row (no scroller, no See All tile). This is the design's two-column Live grid. Both layouts take the same size. An event with only its main market leaves the right half empty (the row keeps its two-column grid so every row lines up). Any other value falls back to 'rail'. While loading, the skeleton uses the same two half-width cells. |
liveCardStyle | live-card-style | 'default' | Live main-card style. 'compact' opts into a slimmer LIVE tag (10px label, 7px icon, 8px corner instead of a pill), a 16px/700 score box top-aligned under the breadcrumb, and a 34px odds row at the default size ('large' keeps 40px). Only the live card reads it. Any other value keeps the default card. |
seeAllStyle | see-all-style | 'default' | See All tile style. 'default' is the 100px tile with a text arrow; 'compact' opts into an 86px tile with a 39px circled arrow, a 14px/700 label and a 10px count. The tile keeps the rail height for its size. |
preliveCardStyle | prelive-card-style | 'default' | Pre-live main-card style. 'compact' opts into an 11px/600 date line (the default is 12px/500). Only the pre-live card reads it. |
teamNameFormat | team-name-format | 'short' | Team names on the event cards. 'short' shows the participant short name (e.g. "Man City"); 'full' shows the full name (e.g. "Manchester City"). Either one falls back to the other when the feed leaves it empty, so a name is never blank. The value ignores case and surrounding spaces ('Full' works); any other value reads as 'short' and logs one console warning. The accessible card labels follow the same setting; emitted event payloads (goma:event-navigate's homeTeam / awayTeam) keep the short name. |
cardWidth | card-width | '' | Fixed width for every card in a rail: the main card, the additional-market cards and the loading skeletons. Any CSS length (e.g. '340px'), or a number read as px (340, '340'), measured over the card's border, which is what a design frame measures. Empty, or a value that is neither, keeps the clamped 260–400px width that follows the viewport. The 'columns' layout ignores it, because its cards fill their cell. |
oddsGap | odds-gap | null | Space between the main card's odds buttons, in px. null keeps 8px. The live and pre-live cards read it; the additional-market cards keep 8px. |
rowGap | row-gap | null | Vertical space between event rows, in px. null keeps 16px. |
liveOddsHeight | live-odds-height | null | Live main card odds row height, in px, at any size. null keeps 40px (34px with liveCardStyle: 'compact' at the default size). Under 40px the odds buttons drop their 40px floor, as on the compact card. |
liveBadgeLeftRadius | live-badge-left-radius | null | LIVE tab left corner radius, in px; 0 squares it. null keeps the pill (8px with liveCardStyle: 'compact'). |
liveTeamNameSize | live-team-name-size | null | Live main card team name font size, in px, at any size. null keeps 16px (18px at size: 'large'). |
cardType | card-type | "auto" | Force a main card type: auto / prelive / live / outright. |
skeletonCount | skeleton-count | 3 | Number of skeleton placeholder cards shown while loading. |
selectedOutcomes | selected-outcomes | [] | Array of betting offer IDs that should appear selected. See Two-way selection sync. |
followSportPicker | follow-sport-picker | true | Listens for goma:sport-select and rewrites the topic's sportId segment. See Integrates with. |
sportPickerSelector | sport-picker-selector | '' | Optional CSS selector scoping followSportPicker to a specific emitter. Empty = match any. |
followRegionPicker | follow-region-picker | true | Listens for goma:region-select and rewrites the topic's locationId segment. See Integrates with. |
regionPickerSelector | region-picker-selector | '' | Optional CSS selector scoping followRegionPicker. Empty = match any. |
followCompetitionPicker | follow-competition-picker | true | Listens for goma:competition-select and rewrites the topic's tournamentId segment. See Integrates with. |
competitionPickerSelector | competition-picker-selector | '' | Optional CSS selector scoping followCompetitionPicker. Empty = match any. |
followTimePicker | follow-time-picker | true | Listens for goma:time-select and rewrites the topic's hoursInterval segment. See Integrates with. |
timePickerSelector | time-picker-selector | '' | Optional CSS selector scoping followTimePicker. Empty = match any. |
defaultMarket | default-market | null | The bettingTypeId of the market to surface as each card's main market. The matching market is moved to the front of every event's market list (the old main shifts one place right, the rest keep their order); an event that doesn't offer it is left unchanged. null = the feed's own market ranking (see Market ordering within a row). Pairs with <goma-filters-settings>'s default-format selection. |
oddsFilter | odds-filter | 'all' | Odds-range filter over the rendered events: 'all' (no filter), 'between_2_3' (keep events whose main market has an outcome with 2 ≤ odd ≤ 3), or 'big_odds' (odd > 3). Events that don't offer the selected defaultMarket stay visible. |
pageSize | page-size | 0 | Events added per Load more click. 0 (the default) disables pagination and renders no button. Requires a topic. See Pagination. |
Top bar (competition separator)
Set showTopBar and a tournamentId to render a competition header above the cards. The title and country flag resolve authoritatively by tournamentId via a one-shot tournament-metadata RPC (getTournamentInfo, retried up to 4 times at 700ms while a loading skeleton shows), falling back to the widget's own feed if the RPC can't name the tournament; topBarTitle overrides the title. The favorite star reads/writes the shared goma:favorites:v1 cache and also emits goma:favorite-toggle.
| Prop | Attr | Default | Notes |
|---|---|---|---|
showTopBar | show-top-bar | false | Render the bar. |
tournamentId | tournament-id | null | Competition to describe. |
topBarTitle | top-bar-title | '' | Optional title override. |
showFavorite | show-favorite | true | Show the favorite star. |
flagBaseUrl | flag-base-url | OM flag CDN | {base}{venueId}.png. |
collapsible | collapsible | true | Allow collapse. |
collapsed | collapsed | false | Start collapsed. |
favoritesApiBaseUrl | favorites-api-base-url | null | Favourites REST base URL. When set (or via config.favoritesApiBaseUrl), star toggles persist to the server via the tab-level sync. See the config table above. |
html
<goma-events-horizontal id="ev" show-top-bar tournament-id="42"></goma-events-horizontal>
<script type="module">
import '@gomagaming/events-horizontal'
document.getElementById('ev').config = { socketUrl: '…' }
document.getElementById('ev').topic = 'custom-matches-aggregator/1/all/42/all/POPULAR/BOTH/10/5'
</script>Card types
The wrapper automatically selects the main game card component based on the event:
| Event shape | Card rendered |
|---|---|
event._type === 'TOURNAMENT' | OutrightRegularOdds |
event.isLive === true | LiveRegularOdds |
| Everything else | PreLiveRegularOdds |
Additional markets are rendered with MarketRegularOdds. All four card types use responsive widths (clamp(260px, 80vw, 400px)) with a transparent inner border. Colours are fully themeable via CSS variables.
Market ordering within a row
Each row's markets are ordered by identity, not by their position in the event's markets array. The widget ranks them against the canonical market list the feed declares (capped by the topic's mainMarketsLimit segment), so the leftmost card is always the same market for a given feed.
This matters on live feeds: a market that is suspended and re-offered arrives as a delete followed by a create, which lands it at the end of the event's markets array. Ordering by array position would therefore make the headline market, and every card behind it, reshuffle on their own while the user is looking at the row.
Markets beyond the canonical list have no feed-declared order; they follow the ranked ones in a stable, deterministic order rather than in arrival order.
A feed that carries both live and pre-match events declares two lists for one sport, one for pre-match events and one for live events, and they can rank the same markets differently. For basketball, the pre-match list puts Over/Under first and the live list puts Home Away first. Each card is ranked by the list for its own event's live state, so a live card and a pre-match card in the same feed can lead with different markets. When the feed declares only one of the two lists, every card is ranked by that list.
When events are supplied host-side via the events property there is no canonical list to rank against (and no live churn to absorb), so the order you declare in each event's markets array is preserved exactly.
The main game card (both LiveRegularOdds and PreLiveRegularOdds) renders a market-name badge: a small rounded pill showing the card's main market name (bettingTypeName, e.g. "Match Result", "Over/Under 2.5") above the odds row, trailed by a short gradient divider. When defaultMarket is set the badge names whichever market each card is displaying. It uses --goma-highlightPrimary (border + divider) on the prelive card and --goma-separatorLine (border + divider) on the live card, plus --goma-textSecondary (label): no new theme tokens.
The cyan gradient border renders only on each event row's main card. The additional-market cards in the same row (and the see-all card) use a plain single-colour border instead. The card components accept an isFirst prop (default true) that toggles between the two borders; the wrapper passes isFirst="false" to the additional-market cards.
Responsive layout
Card widths adapt to the viewport using clamp(260px, 80vw, 400px):
| Viewport | Card width | Behaviour |
|---|---|---|
| 320px | 260px (min) | Narrow mobile: next card peeks in swiper |
| 375px | 300px | Standard mobile |
| 500px+ | 400px (max) | Tablet and desktop |
Events
All events use bubbles: true and composed: true to cross the Shadow DOM boundary. The following are the v1 public API: their detail shapes will not change without a major version bump.
js
el.addEventListener('goma:outcome-select', (e) => {
console.log('Added to betslip:', e.detail)
})Canonical vs alias. Bind to canonical names in new consumer code. Aliases are dispatched in parallel for backward-compat with hosts written before the namespace was introduced; they will be removed in a future major version.
| Status | Event | Detail | Emitted when |
|---|---|---|---|
| Canonical | ready | {} | Widget mounted, initial render done. (No goma:-namespaced version.) |
| Canonical | goma:connection | { state, attempt?, nextRetryMs?, lastError?, attempts? } | Connection state transition: see Reconnection behavior. |
| Canonical | goma:error | { message: string, code: string, component?: string } | Two paths. Connection layer: 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, see Empty / loading / error states, emitting only goma:retry. |
| Canonical | goma:outcome-select | { bettingOfferId: string, outcomeId: string, outcomeName: string, odds: number, eventId: string, marketId: string, bettingTypeId: string|number, eventPartId: string|number, line } | User taps odds to add to betslip. |
| Canonical | goma:outcome-deselect | { bettingOfferId: string, outcomeId: string } | User taps selected odds to remove. |
| Canonical | goma:event-navigate | { eventId: string, homeTeam: string, awayTeam: string, competitionName: string } | User clicks a card, a market card, or the See All button to navigate. |
| Canonical | goma:favorite-toggle | { type: 'competition' | 'event', id: string, isFavorite: boolean }. Event-type toggles also carry eventId (same value as id), kept for legacy listeners | User toggles the top-bar, an outright card's or a game card's favorite star. See the note below for the outright card. |
| Canonical | goma:login-required | { reason: 'favorite', type: 'competition' | 'event', id: string } | Emitted when a favourite star is tapped while logged out; the widget does not change the favourite: the host should open its login flow. |
| Canonical | goma:seo-data | { widget: 'events-horizontal', type: 'sports-event', items: object[] } | Fired once the feed's fixtures resolve, and again only when an event's identity changes: never on an odds tick. See SEO data for the payload contract. |
| Canonical | goma:retry | {} | The error banner's Retry button is pressed. |
| Alias | goma:connected | {} | Dispatched once when the API connection is first established. Prefer goma:connection with state === 'connected'. |
| Canonical | goma:feed-load-failed | { source: 'events', topic } | The events feed got no answer for its topic after every retry (about 55 s). The list leaves its loading state and shows its empty state when it had no rows; after Load more or a reconnect the earlier rows stay. Informational: use it to show your own message or to reload. A topic change or an unmount before that time sends nothing, and a feed that answers late still fills the list. |
The outright card (OutrightRegularOdds, shown for _type === 'TOURNAMENT' events or cardType="outright") carries a competition-favorite star rather than an event one, so its goma:favorite-toggle detail is { type: 'competition', id, isFavorite }: no eventId, because the id is a competition id and advertising it under a field named for an event was misleading. It goes through the same login-gated helper and the same shared goma:favorites:v1 envelope as every other star on this widget: signed out it emits goma:login-required and writes nothing.
TypeScript
The shared type declarations (config shape, theme tokens, event-detail map, the GomaCustomEvent helper, and the Vue GlobalComponents / HTMLElementTagNameMap augmentations) ship with @gomagaming/core at dist/types/index.d.ts. Install @gomagaming/core alongside this widget to pick them up.
ts
const el = document.querySelector('goma-events-horizontal')!
el.theme = { highlightPrimary: '#dc2626' } // autocomplete for token names
el.config = { apiBaseUrl: '...' } // typed config shapets
import type { GomaCustomEvent } from '@gomagaming/core'
el.addEventListener('goma:outcome-select', (e: GomaCustomEvent<'goma:outcome-select'>) => {
console.log(e.detail.bettingOfferId) // string
})@gomagaming/core also augments Vue's GlobalComponents interface, so <goma-events-horizontal> is recognised in Vue templates with full type checking.
Reconnection behavior
When config.socketUrl is set, the widget connects to the WAMP socket on mount. If the connection fails, it retries with exponential backoff. The defaults below are used unless overridden via config.network.retry:
| Attempt | Delay |
|---|---|
| 1 | 1 s |
| 2 | 2 s |
| 3 | 4 s |
| 4 | 8 s |
| 5 | 16 s |
| Max | 30 s cap |
After network.retry.maxAttempts failed attempts (default 5) the widget stops retrying and emits goma:connection { state: 'failed' }. The cadence comes from network.retry.baseMs * 2^(attempt-1), capped at network.retry.capMs. The initial WAMP handshake itself has a hard cap of network.connectTimeoutMs (default 15 s).
Coming back from sleep
A socket cannot be watched while the page is frozen. When a phone sleeps or the browser backgrounds the tab, the radio can drop the TCP connection without a close frame: the page wakes up holding a WebSocket that reports itself OPEN, is silent, and will never fire close. No event announces that, so neither the retry ladder above nor the browser's online event (the network was never reported down) can be triggered by it.
The widget therefore treats a long absence as reason enough to rebuild the connection: when the page becomes visible again (visibilitychange, or a back/forward-cache restore via pageshow) after at least network.resumeReconnectAfterMs in the background (default 20 s), it reconnects and repaints from the fresh initial dump. Brief tab switches are left alone. A widget that had a live connection and lost it reconnects on any resume, however brief.
Set network.resumeReconnectAfterMs: 0 to disable the resume reconnect entirely, including that prompt path.
ts
{
state: 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'failed'
attempt?: number // current retry attempt (reconnecting only)
nextRetryMs?: number // ms until next retry (reconnecting only)
lastError?: string // error message (failed only)
attempts?: number // total attempts made (failed only)
}js
el.addEventListener('goma:connection', (e) => {
switch (e.detail.state) {
case 'connecting':
showSpinner()
break
case 'connected':
hideSpinner()
break
case 'reconnecting':
showToast(`Reconnecting (attempt ${e.detail.attempt}, next in ${e.detail.nextRetryMs}ms)`)
break
case 'disconnected':
// Widget unmounted: clean up host-side state
break
case 'failed':
showError(`Connection failed after ${e.detail.attempts} attempts: ${e.detail.lastError}`)
break
}
})On reconnect, active topic subscriptions are automatically replayed. The widget's internal watch([oddsApi, topic]) fires when the API reference transitions from null back to a live object, triggering a fresh subscribeToFilteredEvents() call. Version-based deduplication in the WAMP client prevents duplicate event processing from the new subscription's initial dump.
Integrates with
Listens for four picker events, each gated by its own follow* property (all default true), rewriting one segment of the current topic in place: no reconnect, just a topic swap:
goma:sport-selectviafollowSportPicker: rewrites the sportId segment.goma:region-selectviafollowRegionPicker: rewrites the locationId segment.goma:competition-selectviafollowCompetitionPicker: rewrites the tournamentId segment.goma:time-selectviafollowTimePicker: rewrites the hoursInterval segment.
Each has a companion *PickerSelector CSS-selector prop (default '' = match any emitter) for scoping to one instance when several pickers of the same kind are on the page. All four attach/detach reactively with their boolean prop and log the rewritten topic when debug is on.
js
const events = document.getElementById('feed')
events.followSportPicker = true
events.topic = 'custom-matches-aggregator/3/all/all/all/POPULAR/LIVE/10/5'
// A goma:sport-select from any <goma-sports-navigation-horizontal> (or
// <goma-sports-navigation-dialog>) on the page rewrites the sportId
// segment in place: no reconnect, no host code.Commonly pairs upstream with <goma-sports-navigation-horizontal> / <goma-sports-navigation-dialog> (sport), <goma-region-filter> (region), <goma-time-filter> (time), and (for competitions) <goma-competitions-list> or <goma-top-competitions-pills>, both of which emit goma:competition-select. Not <goma-competition-filter>: despite the name, that widget was overhauled into a generic host-fed pill bar and emits goma:filter-select, which this widget does not listen for: wire it by hand if you need that pairing.
Downstream, every odds button writes selections into the shared betslip envelope goma:betslip:v4 via useSharedBetslipSync({ subscribeToState: false }): identity fields only; this widget neither reads nor writes the slip-level state (stake, activeTab, oddsChangeBehavior). Any <goma-betslip-floating> or <goma-betslip-sidebar> on the page (or another browser tab on the same origin) picks the selection up with no follow* prop between them. A card tap also emits goma:event-navigate, which <goma-game-details> follows by default (followEventNavigate).
The top-bar and game-card favourite stars read and write the shared goma:favorites:v1 cache, so a star tapped here lights up the matching card in <goma-favorites> and vice versa; setting favoritesApiBaseUrl (or config.favoritesApiBaseUrl) additionally persists toggles to the server via the tab-level sync.
See Cross-widget wiring and Shared state for the full rewrite mechanics, no-op conditions, segment-index reference, and envelope field-by-field contracts.
Empty / loading / error states
The widget shows four visual states:
Loading: skeleton placeholder cards (pulsing blocks mimicking the card layout) are shown from mount until the data source answers. The number of skeletons is configurable via
skeletonCount(default3); set it to match a smalleventLimitso the section doesn't visibly shrink once the events land. Internally this is driven byuseTopicSubscription'sloadedref, not by an event count: a feed that legitimately returns zero events produces the same empty store as a feed that hasn't answered yet, so deriving loading fromevents.lengthwould leave the empty state unreachable."Answers" is a lifecycle fact, not a count: an initial dump landing counts even when it carries zero events, which is what lets state 2 below distinguish "nothing to show" from "still waiting". In host-fed mode, assigning
events(including an explicit[]) is the answer. If neither ever happens. The socket never connects, or a subscribed topic is never answered. The widget's watchdog stops waiting after about 55 seconds once a request for the data was sent, long enough for the connection layer to ask for it again. When nothing could be requested (no connection yet, or no topic) it stops after 15 seconds. A Load more request waits the same 55 seconds before its spinner clears. The widget falls through to the empty state rather than showing skeletons indefinitely. The empty state shows only when the list had no rows: after Load more or a reconnect the earlier rows stay. Re-scoping the topic (via a picker or Load more) starts a fresh cycle.Empty: if the feed answers with zero events for the topic, a centred "No events available" message is shown. The message is localised (en, fr, pt). A feed made up entirely of terminal events (see Data sources) lands here too: filtering them out empties the list even though the feed itself answered with events.
Live with no score: the feed does not carry a score for every live match. The card shows the last score it saw for that match. With none, it draws no score box, at any point in the match, and shows the current part, the same rule as the
<goma-game-details>header. A host that feedseventswith nohomeScoreorawayScoregets the same result. Finished part columns can differ between the two: the list feed sends fewer score rows than the match feed.Error: if a render error occurs, a themed error banner is shown for 8 seconds with a dismiss button and a Retry button (emits
goma:retry). Connection errors after the retry budget is exhausted emitgoma:errorto the host.
Theming
Override any CSS variable by passing a theme object. All tokens are namespaced with --goma-* and applied as inline styles on the host element. The prefix is added automatically: pass just the token name:
js
el.theme = {
backgroundPrimary: '#1a0505',
highlightPrimary: '#dc2626',
buttonBackgroundPrimary: '#dc2626',
}See the Theming guide for the complete token reference, default values, and full theme examples.
i18n / message overrides
Override any bundled translation key via config.messages. Consumer phrases are merged on top of bundled defaults. The fallback chain is: custom key -> bundled default -> fallback locale ('en') -> literal key string.
js
el.config = {
apiBaseUrl: 'https://api.example.com',
messages: {
en: { see_all: 'View All', no_events_available: 'Nothing to show' },
fr: { see_all: 'Tout voir' },
},
}Keys used by this widget
| Key | Default (en) | Used in |
|---|---|---|
live | Live | Live badge |
suspended | Suspended | Suspended market overlay |
no_events_available | No events available | Empty state message |
see_all | See All | See-all card button |
x_markets | No Markets | 1 Market | {count} Markets | Market count on see-all card (pluralized, vue-i18n picks the form from the count) |
add_favorite | Add to favorites | Favorite star aria-label |
remove_favorite | Remove from favorites | Favorite star aria-label (active) |
loading | Loading | Skeleton loading state |
load_more | Load more | Load-more button label |
odds_increased | Odds increased to {value} | Screen-reader live region |
odds_decreased | Odds decreased to {value} | Screen-reader live region |
outcome_locked | Outcome locked | Disabled odds button |
retry | Retry | Error banner retry button |
see_competition | See Competition | Competition link label |
see_game_details | See Game Details | Card navigation aria-label |
see_outrights | See Outrights | Outright card action button |
Post-mount overrides are also supported:
js
el.config = { messages: { en: { see_all: 'SHOW MORE' } } }Two-way selection sync
The host can drive which outcomes appear selected via the selectedOutcomes property. The widget diffs the incoming array against its internal betslip state and adds/removes selections to match. Changes reflect within one frame.
- Host sets
el.selectedOutcomes = ['offer-1']: the widget diffs it against betslip state and adds the selection; the UI highlightsoffer-1. - A user click inside the widget (e.g.
offer-2) adds that selection directly and emitsgoma:outcome-select. If the host doesn't also updateselectedOutcomesto includeoffer-2, the next host assignment of the old array is a no-op (already in sync) rather than removing it. - Host sets
el.selectedOutcomes = []: the widget diffs, removes every selection, and all highlights clear.
Rapid double-clicks on the same odds button are deduplicated: only one goma:outcome-select or goma:outcome-deselect event fires per animation frame.
Embedding examples
Vanilla HTML
html
<goma-events-horizontal id="sb"></goma-events-horizontal>
<script type="module">
import '@gomagaming/events-horizontal'
const el = document.getElementById('sb')
el.config = { /* ... */ }
el.topic = 'custom-matches-aggregator/1/all/all/all/POPULAR/LIVE/10/5'
</script>React
jsx
import '@gomagaming/events-horizontal'
function Feed({ config, topic }) {
return (
<goma-events-horizontal
ref={(el) => {
if (!el) return
el.config = config
el.topic = topic
}}
/>
)
}Vue 3
vue
<script setup>
import '@gomagaming/events-horizontal'
import { ref, onMounted } from 'vue'
const el = ref(null)
onMounted(() => {
el.value.config = { /* ... */ }
el.value.topic = 'custom-matches-aggregator/1/all/all/all/POPULAR/LIVE/10/5'
})
</script>
<template>
<goma-events-horizontal ref="el" />
</template>Performance
Stress test
A Playwright spec at tests/perf/widget-stress.spec.ts automates the frame-budget regression check as part of pnpm test:perf. It streams 100 mock events through a single <goma-events-horizontal> instance with 200 ms odds/score mutations and asserts both the average and 5th-percentile requestAnimationFrame-sampled FPS stay above thresholds (avg ≥ 30 / P5 ≥ 8, with CI-conditional floors) over a 10-second window.
Run it from the repo root:
bash
pnpm run test:perf -- --grep stressFor deeper profiling, boot the docs site at pnpm docs:dev and open /playground in your browser. Open DevTools → Performance, hit record while the events-horizontal widget is selected with mock data + a streaming source, and analyse the flame chart as before. The Playwright spec gates regressions in CI; the playground + DevTools workflow remains the right tool for actively chasing one.
Baseline & targets
| Metric | Baseline | Target |
|---|---|---|
| FPS (100 events, 200ms updates) | ~70 FPS | ≥ 30 FPS |
| Longest task (100 events, 100ms) | 65 ms (scripting) | < 100 ms |
| CLS | 0 | 0 |
| Main chunk (gzip) | 368 kB | < 400 kB |
| Total bundle (gzip) | 506 kB | < 550 kB |
Soak test (memory)
A Playwright spec at tests/perf/widget-soak.spec.ts automates the memory-leak regression check as part of pnpm test:perf. Default mode (CI) runs a 120-second soak with 50 events under 200 ms mutation streaming, snapshots performance.memory.usedJSHeapSize every 15 s, and asserts final-snapshot growth stays under 10 MB. The original 15-minute / 50 MB protocol is preserved verbatim under an opt-in env flag for manual verification before invasive changes. V8's sawtooth GC pattern means peak values will be higher: the verdict uses the final snapshot, not the peak.
Run the short variant from the repo root:
bash
pnpm run test:perf -- --grep soakRun the full 15-minute variant manually (not CI-gated):
bash
SOAK_FULL=1 pnpm run test:perf -- --grep soakperformance.memory is Chromium-only; the spec skips its growth assertions on browsers where the API is unavailable.
Mitigations applied
- Replaced
JSON.parse(JSON.stringify(...))deep clone inEventHorizontalWrapper.vuewith shallowmap(m => ({ ...m })): eliminates the most expensive per-card operation on reactive updates. - Bundle budgets enforced via
size-limitin CI: PRs that regress >5% will fail.
Accessibility
ARIA roles and attributes
| Element | Role | Attributes |
|---|---|---|
Odds button (RegularOdds) | button | tabindex="0", aria-pressed, aria-disabled, aria-label="Name, Odds" |
| Card containers (Live, PreLive, Market, Outright) | link | tabindex="0", aria-label with match/event context |
| Favorite star | button | tabindex="0", aria-label="Add/Remove from favorites" |
| See All button | button | tabindex="0", aria-label="See all N markets" |
| Outright action button | native <button> | Focus-visible ring |
| Error banner | - | role="alert" for screen-reader announcement |
Keyboard navigation
All interactive elements are reachable via Tab. A card or odds button that takes focus outside the visible part of a row slides into view by the shortest distance, and the row's edge fades stay at its edges. Enter activates cards and buttons; Space activates odds buttons and the See All button. :focus-visible ring styling uses the --goma-highlightPrimary token.
Screen-reader support
- Odds price changes: A visually-hidden
aria-live="polite"region announces "Odds increased to X" or "Odds decreased to X" when odds change. - Error state: The error banner has
role="alert", which causes screen readers to announce it immediately.
Testing
Automated
axe-coreruns against all card variant HTML patterns intests/a11y.test.js: zero violations expected on every PR.
Manual smoke procedures
- VoiceOver (macOS): enable with Cmd+F5. Tab through cards and odds; verify announcements. Listen for odds-change announcements during live streaming.
- NVDA (Windows): enable NVDA, navigate with Tab/arrow keys. Verify card labels, odds announcements, and error-banner
role="alert"read-out.
Integration Caveats
SSR/webview: importing this package outside a browser previously threw immediately; fixed with a guard on the shared element factory.
Tokens/Theming:
--goma-backgroundBorderand--goma-favorites(used and documented in the canonical theming guide) are now registered inthemeTokens.js.Misc hygiene: stray comments referencing the deprecated
demo/app insrc/playground.manifest.jsandsrc/playground/mock.jswere reworded.Initialization: this widget shares
@gomagaming/core's i18n singleton with every other widget on the page; mounting two widgets with differentlocalevalues 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, awaitwindow.__gomaCdn.readyfirst, 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
The event lists and the competition lists now wait for a slow or failing provider before they show an empty state. An event list keeps its loading state for about 55 s, long enough for the connection layer to ask for the data again, and the watchdog reports a failure only for a topic that was subscribed. The competition, top competition and region calls make up to 3 tries in
'rpc'mode (top competitions also in its'subscribe'fallback), with a wait of 2 s before the second and 5 s before the third.network.rpcTimeoutMsbounds each try. A provider error now counts as a failed try:getCompetitions,getTopCompetitionsandgetAllLocationsreject instead of resolving with nothing. A late answer of an earlier try that fills the list ends the tries. When every try has failed, the widget shows its empty state (the region filter drops its skeleton) and emits the new informational eventgoma:feed-load-failed({ source, sportId?, topic? }), so a host can show its own message or reload. A sport change during the wait cancels the old tries, and the region call now ignores an answer for a sport the player has already left. The connection layer only gains a comment that links its retry timings to the event-list wait.The event list waits 55 s only after a request for data was sent, and 15 s when nothing could be requested. Load more now waits the same 55 s before its spinner clears, and the match page (game details) waits for the retries before it shows its not-found or empty state.
The suggested bet card (
dataSource: 'recsys') waits up to 55 s for the first answer once it has subscribed to the recommended events, instead of showing its empty state after 15 s. After an answer that still leaves no priced combo, or when no subscribe could be sent, it gives up after 15 s.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 whenfavoritesApiBaseUrlis 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 real0 0still draws. A live<goma-multi-banners>sport card with no score now shows none, where it showed0 - 0. A host that feedseventswith nohomeScoreorawayScoregets no score box in place of0 0: send both as0to 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 as7th Game (2nd Set), where they showed the status, such asIn Progress. In<goma-game-details>,goma:game-endednow carrieshomeScoreandawayScorefor a match with no whole-match row, such as e-Tennis, where it carriednull.New
teamNameFormatsetting ('short'|'full', default'short') on<goma-events-horizontal>,<goma-favorites>,<goma-competitions-list>and<goma-game-details>. It picks the team names the event cards and the Game Details header show: the short name (e.g. "Man City", the current look) or the full name (e.g. "Manchester City"). Either one falls back to the other when the feed leaves it empty. The value ignores case and surrounding spaces; any other value reads as'short'and logs one console warning. The cards' accessible labels follow the setting; emitted event payloads keep the short name.@gomagaming/sports-domainexports theparticipantNames(event, format)helper the cards use.
0.37.4
- Keyboard focus in horizontal rails now stays on screen.
<goma-sports-navigation-horizontal>slides a tile reached with Tab to the centre of the strip, and the event rows in<goma-events-horizontal>,<goma-favorites>and the<goma-competitions-list>detail view slide a card or odds button reached with Tab into view. The event rows also no longer scroll natively under the carousel, which moved their edge fades into the row and left the carousel out of step with what was shown. - 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.orderMarketsByMainMarketstakes an optional{ isLive }andresolveMarketSelectionan optionalisLiveoption for this. A feed that declares only one list ranks every event by that list, as before. - Game cards: a suspended odds button now paints
backgroundDisabledOdds. The restingbackgroundOddsclass 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 readstextDisabledOdds, the same token as the suspended odds button, instead ofinputTextTitleDisabled(this reverses the token choice of a separate change). The suspended button's padlock is now the design's plain 14px padlock (core'slock.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-events-horizontal>gains seven opt-in card geometry options:cardWidth(a fixed width for every rail card and loading skeleton, border included, instead of the clamped 260–400px; a CSS length, or a number read as px),oddsGap(space between the main card's odds buttons),rowGap(space between event rows),preliveCardStyle: 'compact'(an 11px/600 date line on the pre-live card), and, for the live main card,liveOddsHeight,liveTeamNameSizeandliveBadgeLeftRadius(odds row height and team name size at anysize, and the LIVE tab's left corner). Unset, each keeps the current value (clamped width, 8px, 16px, 12px/500, 40px / 34px compact, 16px / 18px large, pill / 8px compact LIVE tab), so the default look is unchanged. The game cards in@gomagaming/uitake the matchingcardWidth,oddsGap,oddsHeight,teamNameSize,badgeLeftRadiusandpreliveCardStyleprops.<goma-favorites>and<goma-competitions-list>carry the same row component, so they are bumped with it.@gomagaming/coreis 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/quickAddAmountsJsonare nowplaceBetHeaders/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
highlightedImagesstring default was indexed character by character and requested bad image URLs on every load. The missions-hubemptyStateImagesillustrations now show.
No widget runtime behaviour changes.
- The betslips'
0.36.0
<goma-events-horizontal>gains alayoutoption ('rail'|'columns', default'rail').'columns'renders each event as a fixed two-column row: the main card and one market card, each half the row width, with no scroller and no See All tile. An event with only its main market leaves the right half empty, and while loading the skeleton uses the same two half-width cells. Switchinglayoutat runtime keeps the rail's market observer (edge fades, slide updates) working.'rail'is unchanged.The
@gomagaming/uigame cards (LiveRegularOdds,PreLiveRegularOdds,OutrightRegularOdds,MarketRegularOdds) andSkeletonCardgain an opt-influidprop that fills the parent's width instead of the rail's clamped 260–400px width. Off by default, so every existing rail keeps its geometry.Two opt-in styles on
<goma-events-horizontal>, both defaulting to the current look:live-card-style/liveCardStyle('default'|'compact'): the compact live main card has a slimmer LIVE tag (10px label, 7px icon, 8px corner instead of a pill), a 16px/700 score box top-aligned under the breadcrumb (26px minimum width, 44px column), and a 34px odds row at the default size ('large'keeps 40px). The card keeps its fixed 160px / 190px height. In@gomagaming/ui,LiveRegularOddstakes the sameliveCardStyleprop andRegularOddsacompactprop (drops its 40px minimum so both label lines fit the 34px row).see-all-style/seeAllStyle('default'|'compact'): the compact See All tile is 86px wide (default 100px) with a 39px circled arrow, a 14px/700 label and a 10px count; its height still followssize.
@gomagaming/coreis bumped for the new utilities in its compiled sheet.The flat 1px frame of the secondary game cards (additional-market, pre-live and outright) reads an optional
--goma-secondaryCardBordertoken, the same one for all three card types. Unset, it falls back tocardBorderLineGradient2, the token it used before, so the default look is unchanged; a host can frame those cards without moving the main card's gradient mid-stop.@gomagaming/coreis bumped for the new utility in its compiled sheet.Moved every shared UI component out of
@gomagaming/sports-domaininto a new@gomagaming/uipackage, which depends on it:The game cards:
RegularOddsand its live/prelive/outright/market variants,SkeletonCardandCountryFlag. Used by every widget that lists events.The betslip family:
BetslipPaneland its selection, summary and bonus pieces, and theGomaDialogmodal 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.
MyBetsPanelandMyBetsCardmoved 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/uiis installed automatically with the widgets that use it.@gomagaming/sports-domainno longer exports any component. It keeps the stores, API clients, composables and the promo-card auto-mode layer, and additionally exportsuseBetslipSystem,useBonusSessionGate,bonusRowTitle,convertOdd,formatRatePercent,formattedDate,formattedTimeandresolveDateLocale. A host importing one of the moved components from@gomagaming/sports-domaindirectly 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:truewas submitted asACCEPT_HIGHER, which accepts a rise. Its replacement,oddsChangeBehavior, says which ofaccept-any,accept-higherandnever-acceptapplies, 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 asACCEPT_HIGHER. Nothing about those bets changes. - A betslip with
acceptOddsChange: falsewas being submitted asACCEPT_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 exportedBETSLIP_STORAGE_KEYrather than a literal. Widget code that sets the old boolean keeps working: it is still available, derived from the policy.- A betslip with
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:
detachBettingOfferFromOutcomekeyed on the OUTCOME rather than on the offer, so a re-price arriving asCREATE(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.attachBettingOfferToOutcomere-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.bettingOfferIdis 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_OFFERrecords now reach both store mirrors whichever WAMP topic carried them: the shared pool the cards read and the singulargameslot<goma-game-details>reads.createpreviously 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.
<goma-events-horizontal>no longer shows the previous scope's events after a re-scope. Re-subscribing does not clear the store (the next dump replaces it wholesale) so between a picker changing the topic and that dump landing, the widget rendered whatever the last scope had left behind. A fast feed made that a flicker; a slow one, an empty one, or one that never answered made it permanent, because the subscription watchdog eventually reports the feed as answered and the stale cards are then presented as the new sport's. Picking a sport could therefore list another sport's matches.The feed's events are now dropped when the SCOPE changes, and only then. A page-size bump (the widget's own "load more", or a host rewriting the
eventLimitsegment directly) is the same feed asking for more of itself, and is untouched, so the no-flicker behaviour that rewrite was built for is preserved. Host-fed embeds (el.events) are untouched too: there the host owns the array.Bundle budget re-measured with the change: 12.26 kB gzip (was 12.04), 10.75 kB brotli (was 10.56); limits moved to the measured figure plus the standard headroom.
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.outcomesand the market reaches the card with its two sides the other way round.sortOutcomesonly ranks outcomes carrying aheaderNameKey, which a handicap outcome does not, so every pair compared equal andArray#sort's stability handed the layout to arrival order. For a two-sided market the comparator now falls through to the provider's outcomecode: numbered codes (#1/#2) compare numerically, so#10does not sort before#2, and the provider's named pairs (#Y/#N,#O/#U,#Odd/#Even) are ranked the waygetOutcomeOrderalready 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.sortOutcomesreturns 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>callsortOutcomeson their own market lists and render the shared odds button, so the new ordering and the newlineongoma:outcome-selectreach them too.The betslip.
RegularOddsrecords the market's line (paramFloat1) on the selection at tap time, and it now survives both cross-widget bridges: thegoma:betslip:v4envelope carries it as an identity field, andgoma:outcome-selectcarries it in its detail. Without that it never reached the widget that reads it.useBetslipOddsSynccompares 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, anduseBetslipLogicrefuses 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: truebesideisAvailable: 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) andgoma:bet-failedlists those legs under a newmovedLineSelectionskey rather than folding them intounavailableSelections, so a host does not word a live match as dead.placeBetBlockedReasongains'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 newplace_bet_blocked_line_movedkey 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,autoplayIntervaland 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 sport1and 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 theconfigobject were never affected by this and are unchanged.
0.32.0
Widgets no longer dispatch a bare
errorevent alongsidegoma:error.Every factory-built widget's app-level Vue error handler dispatched two events for the same failure:
goma:error, and an un-namespacederror. The bare one is gone;goma:erroris unchanged and still carries the same{ message, code, component? }detail.Like every GomaPulse event it was
composedand bubbling, so it did not stop at the widget. It crossed the shadow root, crossed the document, and arrived at whatever the host had onwindow.addEventListener('error', …): as aCustomEventwithmessage,errorandfilenameallundefined, since those areErrorEventfields aCustomEventdoes 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
errorCapturedwalk at the throwing component's parent, so theonErrorCapturedboundary 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 emitgoma:retry.Hosts listening for
goma:errorneed no change. A host listening for bareerroron a widget element should move togoma: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-choicealso reserves the fixture pill's height when a card carries no fixture, so cards keep one height with or without it.
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.
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), andcasino-games. The payload is{ widget, type, items }, wheretypeis'sports-event'or'casino-game'. The event bubbles and is composed, so a single listener ondocumentcatches every widget on the page.Emission fires once when data resolves and again only when the data's identity changes:
markets,scoreandclockare excluded from the identity signature, so odds ticks do not re-fire it. An emptyitemsarray 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.
Integration caveats: fixes from a cross-widget review of every shipped widget from an integrator's point of view.
Cross-cutting fixes:
@gomagaming/core'screateWidgetElement()factory, andlive-chat's andmessenger's own element classes, now guard against importing a widget package in a non-browser context (SSR): theHTMLElementsubclass was previously defined at module top level in every widget package, so importing any of them under server-side rendering threw aReferenceErrorimmediately.
Per-package fixes:
favorites: addedsrc/themeTokens.jsand a:hoststylesheet (previously the package had neither, so it silently rendered core's base theme instead of its documented dark-navy/cyan/amber theme).suggested-bet. Fixed a leaked WAMP subscription: switching a mounted widget'sdataSourceaway fromrpc/subscribenever released the open topic.filters-settings: fixed a dead:host{display:inline-block}CSS rule (the shared element factory's inlinedisplay:blockalways won), so the widget always rendered full-width instead of the intended compact inline trigger.game-details: added amin-heightfallback for the inert/no-eventIdstate in an unsized container.betslip-sidebar,my-bets,competition-filter,suggested-bet,events-horizontal,top-competitions,top-competitions-pills,competitions-list,multi-banners,casino-games,sports-navigation-horizontal. CorrectedthemeTokens.jsregistries that had drifted from actual token usage, added missinggoma:retryEvents-table rows, and other small doc/registry corrections.region-filter: added a missingtypeof window !== 'undefined'guard on a window listener.live-chat: correctedcustom-elements.json'smembersarray (missingtheme/configaccessors, and an inaccurate description).sports-domain: corrected a stale code comment that seeded a documentation error in two Missions widgets.
Three fixes (
favorites,game-details,filters-settings) change real rendering and are flagged indocs/guide/integration-caveats.mdas needing human visual verification before release.
0.28.0
<goma-events-horizontal>,<goma-favorites>and<goma-competitions-list>: add ashowFadesprop (show-fadesattribute, defaulttrue) that toggles the edge fades on each event's market rail.The fades are painted as a gradient from
--goma-backgroundSecondary, so they only read correctly when the host paints that exact surface behind the widget. On any other background the fade showed as a smear across the rail edges rather than a hint that it scrolls, and there was no way to turn it off. Default staystrue, so existing embeds are unchanged.All three widgets render the same market rail from byte-identical copies of the same component, so the prop lands on all three together:
<goma-favorites>applies it to its favourite-event cards and<goma-competitions-list>to the event cards in its opt-in drill-in detail view.<goma-events-horizontal>,<goma-favorites>and<goma-competitions-list>: add asizeprop (sizeattribute,'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 atsize="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-codedcalc()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
sizevariants: the design'ssize=defaultandsize=largeframes carry identical card chrome, so each value below is the same number in both.What a host actually sees on the game card:
was now Odds row height 48px 40px Odds button padding 8px sides / 4px top+bottom 8px all round Odds button corner 6px 4px Card padding, top / bottom 4px / 12px 8px / 10px Breadcrumb row height 24px 16px Competition name weight 300 500 Team name weight 600 700 Team name line height 20px 16px Gap between the two team rows 4px 6px ( defaultonly)Market badge height 14px 16px Market badge side padding 12px 8px 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.
RegularOddsis 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 atdefaultand 144px → 142px atlarge. Its odds row is the last child of a fixed-heightjustify-betweencolumn, 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'ssize=largevariant 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.
sizeis set on the feed widget and threads down to the cards, the same shapedefaultMarketandoddsFilteralready use: the cards themselves gain asizeprop 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/coreis 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.- The secondary market card's inner column follows the card height by the same relationship as before, re-derived from the new padding:
0.26.0
Stop publishing source maps and package source.
dist/*.js.mapwas emitted by the shared Vite preset and swept into every tarball byfiles: ["dist/", "src/"], with the original source inlined insourcesContent.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 withsourcemap: falseandfilesno longer carriessrc/.@gomagaming/corekeeps the two paths its export map actually points at:src/styles/main.cssandsrc/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.23.0
Finished matches leave the feed, and game details can no longer skeleton forever
Tapping a card whose odds all showed
-opened a game-details view that sat on skeletons indefinitely: no markets, no empty state, no way out. Pulling that thread turned up several independent causes, plus a set of match-lifecycle behaviours the widgets had never carried over from the source sportsbook.Nothing handled a match ending. The provider deletes an ended event's betting offers but keeps the event itself in the aggregator for a while, so the feed kept rendering a card with
-for every odd, and that card still opened a details page with nothing left to bet on. Events that have reached a terminal state (ended, cancelled, walkover, abandoned, retired) are now filtered out of<goma-events-horizontal>, and<goma-game-details>renders a dedicated "this game has ended" state: the header stays so the teams and competition remain readable, but the score, match clock, LIVE badge and kickoff time all go. Interrupted matches are untouched: they can resume. Host-fed feeds can mark an event by settingstatusIdon it.isLivewas decided once, at dump time, and never revisited. A match that kicked off mid-session kept rendering as prematch until the next full dump, and one that finished kept its live styling. Match updates now recompute it: on the live list it up-flips at kick-off and never down-flips (only a server delete takes an event out of that list) while on the single-match surface behind<goma-game-details>it tracks the status in both directions.extraTimeis cleared on status and match-part transitions.A match delete arriving mid-load flashed not-found over a match that was about to appear. Deletes for the previous subscription are ignored while a match-details dump is still in flight.
Odds went missing from cards whose offer arrived in an earlier dump. On feeds that accumulate (favourites, competition detail, banners, paginated lists) only new entities are mapped, but they were being matched against the new offers alone. An outcome whose offer was already in the store found nothing and rendered
-for a price that was present and ticking. Outcomes are now mapped against the full offer pool on every accumulating feed. This is a second, independent source of the-cards above.Loading was inferred from a flag that only the initial dump could clear. Game details gated its skeleton, its empty state and its not-found state on loader flags that ship as
trueand are cleared solely by the WAMPafterInitialDumpcallback. Every path where that dump never lands (a rejected initial-dump RPC, a topic that doesn't exist, a subscribe that bailed before the socket was ready) therefore pinned the widget on skeletons AND made both terminal states unreachable, because each is a!loadingbranch. "Answered" is now a lifecycle fact per event and per market tab, with a 15-second watchdog, so a feed that never replies falls through to the right terminal state instead of waiting forever. A subscription that starts late (a host that setsconfigaftereventId) also counts as a fresh cycle, so a slow start is not mistaken for a missing match. The subscribe actions clear their own flag when they bail out or when the provider module swallows a failure.The terminal states could latch permanently. The match-details subscriptions were owned by the header and markets-list components, which sit behind the ended and not-found branches, so entering either state unmounted the very watchers that would have re-subscribed, and an in-flight dump was discarded with nothing left to retry it. Both now live at widget scope, as they do in the source sportsbook, and the markets feed is released explicitly when a match ends rather than as a side effect of a component unmounting.
The default market tab could point at an empty feed. The active tab was picked as the first market group by position, falling back to the group's
idwhen it had nogroupKey: on sports with sparse market metadata that selected a group with no markets, or built a topic that does not exist. Groups the provider does not confirm as carrying markets, and groups with nogroupKey, are now hidden from the tab strip and skipped by the auto-select; among the rest theisDefaultflag wins, then the leftmost. With no usable group the widget stays on the catch-allallMarketsfeed, which carries every market the match has.Match-part names matched fewer languages than the source. The part-name patterns are now the union of both sets, adding the Finnish, Romanian and Estonian forms, the reverse word order (
Set 2as well as2nd Set) and whitespace-free variants (2.Set). Part names decide which bucket a score row lands in, so this affects score rendering wherever those markets appear.@gomagaming/sports-domaingains four exports for the above:isTerminalEvent,extractEventStatus,EVENT_STATUSESandTERMINAL_EVENT_STATUS_IDS. Mapped feed events and host-fed events both now carrystatusId.
0.22.0
Fix
<goma-events-horizontal>showing skeleton cards forever when a feed legitimately returns zero events.Loading was inferred from store content (
events.length === 0plus a flag that only ever flipped on a non-empty list), which cannot distinguish "an empty dump landed" from "no dump yet", so an events-less topic never left the loading state and its empty state was unreachable. It surfaced on a cold load into a sport with no live games.useTopicSubscriptionnow owns the feed-load lifecycle and returns aloadedref: true once an initial dump lands (whether or not it carries events), reset on every topic change, and released by a 15s watchdog for the dump that never arrives at all. A subscribe that was a no-op because no session existed yet, or a provider that went silent. Neither case can ever callafterInitialDump, so no dump-based signal alone can cover them. The existingonLoadedcallback is unchanged and still means strictly "a dump landed".Consumers should render loading from
loaded, never from an event count. In host-fed mode an expliciteventsassignment (including[]) is the answer.<goma-competitions-list>'s drill-in detail adopts the same ref. Its own flag already handled the empty dump, but nothing released it when no dump arrived at all, so a detail opened before the socket finished connecting kept its skeleton indefinitely. Pagination still settles on dumps only: a watchdog expiry must not count as a short page, or Load more would be retired for good.Also raises events-horizontal's bundle budget to 11.5 kB gzip / 10.5 kB brotli; the fix needed ~480 B and the previous 11 kB cap left only ~180 B of headroom.
0.20.0
Add opt-in load-more pagination to
<goma-events-horizontal>. A newpageSizeprop (default0= off) renders a "Load more" button under the list; each click grows the feed by bumping the topic'seventLimitsegment. Any topic the widget did not write itself re-seeds the page counter, so a picker rewrite, a host reassignment, and a host bumpingeventLimitdirectly all start a fresh page. If a requested page never arrives, a watchdog clears the spinner after 15s rather than latching the button. Adds theload_morelocale key to French and Portuguese.Stop event cards reshuffling their markets on live feeds
Each card's row of markets was selected by array position:
markets[0]for the headline market,slice(1, n)for the rest. On a live feed a suspended market is re-offered as a delete followed by a create, and the re-created market lands at the end of the event'smarketsarray, so the headline market and every card behind it reshuffled continuously.Markets are now ordered by identity (
bettingTypeId) against the canonical market list the feed declares, matching how the reference sportsbook selects them. Host-fed embedding is unaffected: with no canonical list to rank against, the order declared in each event'smarketsarray is preserved.Adds
orderMarketsByMainMarketsto@gomagaming/sports-domain.
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 eventdetailalone, which carried nobettingTypeId, a tick before the complete shared-storage snapshot arrived. The inbound sync then skipped the row because itsoutcomeIdwas already present, so the partial first writer won permanently.Fixed on both sides:
goma:outcome-selectnow carriesbettingTypeIdandeventPartId(it already carriedoutcomeId, 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,
undefinedandnullall count as missing, so the repair does not depend on how a producer spells "unknown"; anullon 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-selectdetail arrives without abettingTypeId(a host driving the betslip from its own UI, where no richer snapshot exists to repair the row) the betslip logs aconsole.warnnaming 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 forwardbettingTypeId/eventPartIdfrom the banner to the odds button. TheeventIdpath 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
RegularOddscomponent.
0.15.0
events-horizontal: add an optional collapsible top bar (competition separator). A leading country flag, a title + flag resolved by tournament id (with a loading skeleton), an optional widget-persisted favorite star (shared
goma:favorites:v1envelope +goma:favorite-toggle), and a collapse chevron.sports-domain: new shared
CountryFlagcomponent,useSharedFavoritesSync, and agetTournamentInfo(tournamentId)betting method (single-tournament metadata by id); the main event cards now resolve their country flag from the flag CDN byvenueId, fixing blank flags with live data.Favourite star toggles now persist to the server from any favourites-aware widget, not only the favourites page.
useFavoritesServerSyncis now a tab-level singleton: it reads the resolved identity from the shared session and is armed once with the favourites REST config (favoritesApiBaseUrl+ operator), then seeds and pushes add/remove deltas for the whole tab. Any widget can arm it viauseFavoritesPersistence(state, config):<goma-events-horizontal>does so whenfavoritesApiBaseUrl(orconfig.favoritesApiBaseUrl) is set, so a star tapped on a page with no<goma-favorites>still persists (and no longer gets silently discarded by the favourites page's next server seed). No-op when no favourites REST config is supplied, so existing integrations are unchanged.favorites: new
<goma-favorites>page widget. A lazy accordion of favourited competitions and events with market-rich cards, server-persisted star toggles (user-data-service REST) and a cross-widget shared cache.sports-domain:
createFavoritesApiREST client,useFavoritesServerSync, extendeduseSharedFavoritesSync(events), and an additive multi-match subscription (subscribeToMatchAdditive+sports.favoriteEvents) so many favourite games resolve by id and tick live at once.core: typed
goma:favorite-toggledetail ({ type, id, isFavorite }).events-horizontal: emit the typed
goma:favorite-toggleshape from the top-bar star.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-requiredevent ({ reason: 'favorite', type: 'competition' | 'event', id: string }) so the host can open its login flow. A sharedguardedFavoriteTogglehelper and auseSharedSession().isLoggedInsignal 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.favorites: harden the accordion. A section's live WAMP feed is now dropped when it's collapsed or filtered out of the sport view, so only on-screen sections stream odds in the background. The first section auto-opens only once every competition's metadata has settled (with a stall-timeout failsafe), so it lands on the true top section instead of whichever resolved first; a deliberate collapse-all is respected through background rebuilds. A transient
getTournamentInfofailure now retries so a competition can't be left permanently hidden under a sport filter.sports-domain:
useSharedStoragecloses itsBroadcastChannelon scope dispose (fixes the leaked channel + its teardown "Channel closed" noise). The betslip's shared-session handle is created once in setup rather than per place-bet 401 (no more per-failure channel leak). An explicitsessionMaxAgeMsof0(force-re-exchange) is honoured instead of falling back to the default.events-horizontal: fix the competition top bar staying on its loading skeleton for the first-selected competition. The
getTournamentInfoRPC can race WAMP connect and come back empty, and nothing re-attempted it. It now retries for the active competition and shows the loading skeleton while the authoritative name resolves (rather than flashing the unreliable feed name). Corrects thegoma:favorite-togglepayload shape incustom-elements.jsonto{ type, id, isFavorite }.
0.12.0
Default-format market + odds-range filtering (ports the source sportsbook's markets filter):
<goma-events-horizontal>gains two props:defaultMarket: thebettingTypeIdof the market to surface as each card's main market. The matching market is moved to the front (the old main shifts one place right, the rest keep their order); an event that doesn't offer it is left unchanged.oddsFilter:'all'(default) |'between_2_3'|'big_odds', client-side filtering the rendered events by whether the selected/main market has an outcome inside the range. Events that don't offer the selected market stay visible.
@gomagaming/sports-domainexports the pure helpersmoveMarketToFront,isOddInOddsRange, andeventPassesOddsRange; and the pre-live event card now shows the market-name badge (matching the live card).
0.9.2
- events-horizontal: the cyan gradient border now renders only on each event row's main card; the additional-market cards in the row use a plain border. The game cards (
PreLiveRegularOdds,OutrightRegularOdds,MarketRegularOdds) gain anisFirstprop (defaulttrue, so other consumers keep the gradient).
0.9.0
New widget
<goma-competitions-list>: vertical list of competitions grouped by region, scoped per sport.Each region renders as a collapsible card (flag + region name + caret in the header, competition rows with separator lines in the expanded body), matching the design. Multi-open accordion; the first region card is expanded by default, controlled via
initialOpenRegionIds.- Reuses
useCompetitionsSource+useLocationsSource; joins them client-side onvenueIdto build the per-region groups. - Three data sources via
dataSource:'prop'(host-fed viael.competitions/el.regions),'rpc'(one-shotgetCompetitions+getLocationson connect; default),'subscribe'(placeholder, falls back to RPC). sportIdis mandatory (defaults to 1 = Football). Pair with the sports picker viafollowSportPickerto re-fetch on sport change.- Emits
goma:competition-selectwith the same payload as<goma-competition-filter>: downstream<goma-events-horizontal>withfollowCompetitionPicker: truecooperates with no changes. - Also emits
goma:region-toggle(informational) when a region accordion card is opened or closed.
Released alongside the rest of the lockstep family at a minor bump.
- Reuses
New widget
<goma-multi-banners>. A swipeable carousel of injectable banner cards, three types in one widget: sport events, casino game promos, and info banners.All data is host-fed via the
bannersarray (no CMS). For a sport banner, settingeventId(live or prelive) makes the widget resolve teams, start time, score and odds itself; without it the host supplies those fields inline. The background image is always host-fed.- Sport cards adapt to event state: live cards show the running clock/part and score (
home - away), prelive cards show the kickoff date + time. - Carousel chrome: prev/next arrows, pagination dots, swipe/drag, opt-in autoplay (off by default). The track is clipped + isolated so nothing bleeds over neighbouring widgets.
- Emits
goma:banner-select(every banner),goma:event-navigate(sport-card body tap → game-details), andgoma:outcome-select(sport odds tap → betslip, same shape as events-horizontal). - No new theme tokens; all colours map to existing
--goma-*tokens.
@gomagaming/sports-domainadditions (additive, no change to existing paths):- New action group
sports.bannerEvents(createBannerEventsActions): a multi-event subscription manager that accumulates events intobettingStore.events(skipStoreCleanup) so several event cards keyed by id coexist with live odds, unlike the singular-gamematchDetailspath. - Public exports for
useGameScoresTallOddsanduseGamePartsAndTimesTallOddsso other widgets can render live scores/clocks from a store event.
- Sport cards adapt to event state: live cards show the running clock/part and score (
0.8.0
New widget
<goma-top-competitions>: sidebar "Top Competitions" list.A vertical list of competition rows (sport icon + region flag + name) with a section title above. Selecting a row emits
goma:competition-selectwith the full payload (competitionId, competitionName, sportSportId, competitionVenueId, previousCompetitionId).- Three data sources via
dataSource:'prop'(host-fed),'rpc'(one-shotpopularTournamentscall on connect; default),'subscribe'(placeholder, falls back to RPC). - Sport scope set once via
config.sportId; no picker-chain wiring. - Sport icons resolved off
iconBaseUrl(/icons/sports/dark_mode/{sportId}.svg), flags offflagBaseUrl({venueId}.png). Same conventions as the rest of the filter family. - New composable
useTopCompetitionsSourcein@gomagaming/sports-domainwraps the existingoddsApi.betting.getTopCompetitionsRPC.
Released alongside the rest of the lockstep family at a minor bump.
- Three data sources via
0.7.0
Playground scenario presets, live theme-token editor, and family-wide picker-coupling defaults.
Widget runtime defaults
- events-horizontal:
followSportPicker,followRegionPicker,followCompetitionPicker,followTimePickerall flip fromfalsetotruein the element-class defaults and the playground manifest. Drop a sibling picker or filter on the page and the events feed auto-couples without any explicit config. Consumers that don't want the coupling set the flag tofalse(or the matching HTML attributefollow-sport-picker="false", etc.). - region-filter:
followSportPickerdefault flips totruein the element class + manifest. The widget will re-fetch the region list whenever a sibling sports picker emitsgoma:sport-select. - competition-filter:
followSportPickerandfollowRegionPickerdefaults both flip totrue. Drops the manual wiring step from the picker-chain composition. - region-filter / competition-filter: playground manifest's
sourcedefault flips from'mock'to'rpc'. Aligns the manifest with the element class's owndataSource: 'rpc'default; consumers of the published widgets are unaffected (mock was a playground-only convenience).
Breaking-ish note: a host page that mounts multiple of these widgets side-by-side without setting
follow*Pickerexplicitly will now see them auto-react to each other's selection events. The old isolated behaviour is restored withfollow*Picker: false.Playground SPA additions (not published; bundled into the docs site at
/playground)- Scenario presets: a named scenario is an ordered set of widgets
- per-instance configs + theme + token overrides + locale. Three built-ins ship:
full-betting(filters row + sports nav + events + game details + sidebar betslip),mobile-layout(dialog picker + events + floating betslip), anddark-neon-blue-showcase. TopBar gains a Scenario<select>that replaces widgets / configs / theme in one mutation; the dropdown auto-flips to "Custom" the moment the user diverges from the preset.
- per-instance configs + theme + token overrides + locale. Three built-ins ship:
- Live theme-token editor: new Inspector "Theme" tab grouped by prefix (Backgrounds, Text, Icons, Highlights, …). Edits write through
store.setThemeToken('--goma-X', '#hex')to the global override layer (sparseRecord<--goma-key, hex>), which Stage's fast-path watcher pushes viael.theme = …to every mounted element._applyTheme()writeshost.style.setProperty(--goma-X)reactively, so token edits repaint with no Vue remount. - Share URL: v3 envelope additively carries
themeTokensandactiveScenario. Default values are dropped from the encoded payload (size optimisation, hydrate re-applies defaults). Foreign payloads with poisonedthemeTokens(non-object, arrays, non-string values) are normalised back to{}inhydrate(). dark-minttheme renamed todark-neon-blue: palette pulled verbatim from the new Figma tokens export (Uniclub Dark Neon Blue). Highlights / live tags / button-primary flip from mint green (#5cffc0,#00ffbc) to neon cyan (#29f2ff); secondary accent flips from indigo#5b56ffto purple#8750ff. Backgrounds, text, icons, separators, dialog background are unchanged. New tokens added: inputs, alerts, betslip, game header, hero card, tickets, stats, shadows. Persisted state or share URLs that pintheme: 'dark-mint'fall back toTHEMES.defaultafter hydrate.
Tests
- New
tests/playground-scenarios.test.js: 19 vitest cases coveringapplyScenario,setThemeToken+activeScenarioCustom-flip,resetThemeTokens, share URL round-trip with the new envelope fields, hydrate normalisation of malformed payloads. - New
tests/integration/playground-scenarios.spec.ts. 4 Playwright cases: scenario → DOM mount, dark-neon-blue palette assertion (backgroundPrimary+highlightPrimary), live token edit without remount (element identity preserved), share URL round-trip across pages. tests/integration/playground-inspector.spec.ts: tab list extended with the new "Theme" tab.
- events-horizontal:
0.6.0
New widget:
<goma-competition-filter>. Horizontal pill row of competitions (tournaments, leagues) with a hardcoded "All" pill on the leftmost slot, single-selection state, and optional competition icons resolved officonBaseUrl(same convention as sport-navigation,competitions/{id}.svg). Sister widget to<goma-region-filter>; same data-source surface, same picker-chain pattern, plus an additional client-side filter wired to the region picker.What's new
- New package
@gomagaming/competition-filter. Element:<goma-competition-filter>. Reference docs:docs/widgets/competition-filter.md. - Hardcoded "All" pill emits
{ competitionId: 'all', competitionName: <localised> }viagoma:competition-select: maps straight to the matches-aggregator topic's literalallno-filter tournamentId segment. - Opt-in
followSportPicker(re-fetch on sport-select) andfollowRegionPicker(client-side filter onvenueIdvia the picked regionId;'all'disables). Both resetselectedCompetitionIdto'all'and emitgoma:competition-selecton context change so downstream listeners clear their tournamentId filter in the same tick.
@gomagaming/events-horizontaladditions- New
followCompetitionPicker+competitionPickerSelectoropt-in props. Third in the picker chain: listens forgoma:competition-selectat the window and rewrites segment index 2 (tournamentId) of the matches-aggregator topic. The existingrewriteTopicSegment(topic, idx, value)helper does the lifting: adapter-resolved prefix when available, canonical'custom-matches-aggregator/'fallback otherwise (works in mock mode + pre-WAMP-connect). - Playground manifest gains a
followCompetitionPickertoggle under "advanced".
@gomagaming/sports-domainadditionsuseCompetitionsSource(state): public composable. Watchesstate.sportId+state.regionId+state.dataSource; callsoddsApi.betting.getCompetitions({ sportId })on connect and mirrors avenueId-filtered slice ofbettingStore.competitionsintostate.competitions. The region filter runs client-side without requiring a re-fetch.subscribeToCompetitions({ sportId, afterInitialDump })stub on the everymatrix betting module (warn-once + RPC fallback, same shape assubscribeToLocations). Backend topic for live competition updates is deferred.
@gomagaming/coreadditions- Four new bundled i18n keys per locale (en/fr/pt):
competition_filter_all_label,competition_filter_aria,competition_filter_empty,competition_filter_live_events_count(vue-i18n 3-form plural for the live-count badge).
gomawt playground
competition-filterjoins the registry, typesWidgetIdunion, storeALL_WIDGETS, VitePress theme side-effect import, Stage event-log allow-list (goma:competition-select), and EventLogPanel category classifier.- demo entry-file imports added to playground/isolation/stress/ soak entries; workspace dep +
optimizeDeps.excludeentry indemo/.
Three-layer chain ready out of the box: drop a sports navigation picker, a region filter, a competition filter, and an events-horizontal feed on the same page; turn on the relevant
follow*Pickerflags and the topic chain{sportId}/{locationId}/{tournamentId}updates segment-by-segment with no host glue and no WAMP reconnect.- New package
Drop the unused
operatorIdfield from the public widget config surface. The field was declared in@gomagaming/coredefaults + TypeScript types but never read anywhere on the data path:ucsOperatorIdis the only operator identifier the EveryMatrix adapter (anduseBetslipLogic) consume. Settingconfig.operatorIdon a widget element now logs an "unknown config key" warning indebug: truemode instead of being silently accepted.Migration: consumers using
config.operatorIdshould rename toconfig.ucsOperatorId. No behavioural change for consumers who already only setucsOperatorId(which the docs have always recommended).@gomagaming/corechanges:createWidgetElement.js:COMMON_DEFAULTSno longer seedsoperatorId; theknownTopLevelKeysvalidator set drops it.types/index.d.ts: removedoperatorId?: stringfrom theGomaWidgetConfiginterface.
@gomagaming/betslip-sidebardocstring tweak:custom-elements.jsonexample endpoint placeholder changed from/place-bet/4313/v2/betsto/place-bet/2838/v2/bets(matches the staging operator id used by the playground EM demo preset). No behavioural change.
gomawt playground:
- The "Connection" panel drops the
operatorIdinput row; share-URL envelope, persistence, types, and preset filler all scrubbed in lockstep so older share URLs that carriedoperatorIdare silently ignored on hydrate (the field falls out ofSTATE_DEFAULTSand the validator'sstringFieldslist). - New mobile UI: ≤760 px viewport gets a bottom tab bar (Widgets / Stage / Inspect) routing the body panels; the topbar folds Mode/Theme/Locale/API URL behind a
⋮disclosure; Connection panel grid collapses to a single column; tab targets meet 44 px touch-target guidance and inputs use 16 px font to suppress iOS focus-zoom. - EM demo preset refreshed to the NDW staging endpoint (
wss://api-ndwstage.everymatrix.com/v2, realmwww.demo1-stage.everymatrix.com, ucsOperatorId2838).
VitePress docs site:
- Locked to dark mode (
appearance: 'force-dark'): the appearance toggle is hidden from the nav. The playground's dark--bg-*tokens no longer two-tone against a light VitePress chrome. - Homepage, introduction, publishing, and widget reference docs refreshed to current state: nine widgets shipped, eleven publishable packages in the lockstep group, example URLs scrubbed of the previous client-specific staging hostnames (now use generic
api.example.com/sports-api.example.complaceholders), exampleucsOperatorIdliteral updated to2838. Three new widget docs (region/competition/time-filter) added to the homepage feature sections.
New widget:
<goma-region-filter>. Horizontal pill row of regions (countries) with a hardcoded "All" pill on the leftmost slot, single-selection state, and an optional flag glyph per pill. Sources its region list from a one-shot WAMP RPC call on connect, with a placeholdersubscribemode that currently falls back to RPC until the backend exposes a live-update topic for locations.What's new
- New package
@gomagaming/region-filterpublished in lockstep with the existing widgets. Element:<goma-region-filter>. Reference docs:docs/widgets/region-filter.md. - Hardcoded "All" pill emits
{ regionId: 'all', regionName: 'All' }viagoma:region-select(canonical "no filter" sentinel, matches the matches-aggregator topic format wherelocationId='all'). - Flag glyphs rendered from a configurable CDN: default
flagBaseUrlis the upstream Glastcoper flag pack, resolved as${flagBaseUrl}{regionId}.png. Falls back to a globe glyph when a flag fails to load. - Opt-in
followSportPickerautomation (mirrors<goma-events-horizontal>): when enabled, the widget listens at the window forgoma:sport-selectevents emitted by sibling sports-navigation widgets and rewrites itssportIdso the region list refetches automatically. OptionalsportPickerSelectorscopes the listener to a specific picker for multi-instance disambiguation.
@gomagaming/events-horizontaladditions- New
followRegionPicker+regionPickerSelectoropt-in props. Mirror the existingfollowSportPickerplumbing but listen forgoma:region-selectand rewrite the locationId segment (index 1) of the matches-aggregator topic. The region-filter's hardcoded "All" pill emitsregionId: 'all'which lines up exactly with the matches-aggregator topic's literalallno-filter segment: no special-casing needed. Drop a<goma-region-filter>alongside an events-horizontal feed and setfollow-region-pickeron the latter; selections flow through with no host glue and no WAMP reconnect (the existinguseTopicSubscriptionwatcher rebinds the topic in place).
@gomagaming/sports-domainadditionsuseLocationsSource(state): public composable that drivesstate.regionsfrom the WAMP API. Watchesstate.dataSource,state.sportId, and the apiContainer's connection; on connect, callsoddsApi.betting.getLocations({ sportId })and mirrorsbettingStore.locationsinto the widget's reactive state.subscribeToLocations({ sportId, afterInitialDump }): new stub method on the Everymatrix betting module. Logs a one-time warning and falls back to one-shotgetAllLocationsuntil the live topic is wired backend-side. Returns a no-op unregister so the composable's cleanup contract stays uniform with the other subscribe methods.
@gomagaming/coreadditions- Three new bundled i18n keys across
en.json,fr.json, andpt.json:region_filter_aria,region_filter_empty.
gomawt playground
region-filterjoins the playground registry, storeALL_WIDGETS, typesWidgetIdunion, and VitePress theme side-effect imports. The embedded/playgroundroute can mount it alongside the other six widgets.
- New package
New widget:
<goma-time-filter>. Horizontal pill row of time-interval filters with a hardcoded "All" pill on the leftmost slot. Pure host-fed; no WAMP transport, no sport-scoping, no API container.Lighter than the rest of the filter family: no
setupAppplumbing on the element, no shared composable in@gomagaming/sports-domain. Just anintervalsprop, aselectedTimeValueselection state, and agoma:time-selectevent.What's new
- New package
@gomagaming/time-filter. Element:<goma-time-filter>. Reference docs:docs/widgets/time-filter.md. - The host feeds
el.intervals = [{ label, value }, …].labelis rendered verbatim (hosts pre-localise, only the hardcoded "All" pill is locale-driven, via the bundledtime_filter_all_labelkey).valuematches the matches-aggregator topic's hoursInterval segment ('0-1','0-24','24-48', etc.). Malformed entries drop silently; duplicates byvaluekeep the first occurrence. - The hardcoded "All" pill emits
{ timeValue: 'all', timeLabel: <localised>, previousTimeValue }viagoma:time-select: maps straight to the matches-aggregator topic's literalallno-filter hoursInterval segment.
@gomagaming/events-horizontaladditions- New
followTimePicker+timePickerSelectoropt-in props. Fourth in the picker chain. Listens forgoma:time-selectat the window and rewrites segment index 3 (hoursInterval) of the matches-aggregator topic withdetail.timeValue. Reuses the existingrewriteTopicSegment(topic, idx, value)helper (with its canonical-prefix fallback so mock-mode + pre-WAMP-connect rewrites still land). - Playground manifest gains a
followTimePickertoggle under "advanced".
@gomagaming/coreadditions- Three new bundled i18n keys per locale (en/fr/pt):
time_filter_all_label,time_filter_aria,time_filter_empty.
gomawt playground
time-filterjoins the registry, typesWidgetIdunion, storeALL_WIDGETS, VitePress theme side-effect import, Stage event-log allow-list (goma:time-select), and EventLogPanel category classifier.- demo entry-file imports added across playground/isolation/stress/ soak; workspace dep +
optimizeDeps.excludeentry indemo/.
Four-segment chain ready out of the box: drop a sports navigation picker, a region filter, a competition filter, a time filter, and an events-horizontal feed on the same page; turn on the relevant
follow*Pickerflags and the topic chain{sportId}/{locationId}/{tournamentId}/{hoursInterval}updates segment-by-segment with no host glue and no WAMP reconnect.- New package
0.5.2
- packages: wire up the now-live
gomawt.gomagaming.comdocs subdomain across npm-facing surfaces. Adds ahomepagefield to each of the eight publishablepackage.jsonfiles so the npm sidebar shows a "Homepage" link again (widget packages point at their per-widget docs page under/widgets/<name>;@gomagaming/coreand@gomagaming/sports-domainpoint at the site root). Replaces the two_coming soon_Documentation bullets in each README with real links togomawt.gomagaming.com/widgets/<name>(full reference) andgomawt.gomagaming.com/playground(live playground). No code change.
0.5.1
events-horizontal: flip
followSportPickerdefault totruein the playground manifest so integrators landing on the playground see the events feed auto-follow any sports picker they enable. The shipped widget's element-class default (followSportPicker: false) is unchanged: consumers who don't opt in viaconfig.followSportPickeror the HTML attribute still get the old behaviour.Playground + maintenance polish:
@gomagaming/events-horizontalnow exports its ownpackage.json("./package.json": "./package.json"in the exports map). Lets consumers read the package version without import-resolution errors; the embedded playground uses this to render its lockstep version badge.@gomagaming/sports-navigation-dialog's playground mock fixture now driftsliveCountevery 4 seconds, matching the horizontal sister. Previously itssubscribe()was a no-op; the dialog's badge UX now exercises while the dialog is open. Initial dataset unchanged.- Source comments scrubbed of stale internal references throughout the eight published packages. No behaviour change.
packages: scrub private-repo URLs from npm-facing metadata and rewrite client-facing READMEs. Removes
repository,homepage, andbugsfields from all eight publishablepackage.jsonfiles: the gomawt GitHub repo is private, so these fields were leaking a 404-on-anonymous link into the npm sidebar. Rewrites the six widget READMEs and the two internal-package READMEs (@gomagaming/core,@gomagaming/sports-domain) with a client-dev-targeted template: blockquote one-liner, framework-agnostic note, install snippet with.npmrc+NPM_TOKENauth blurb, widget-specific quick-start, and placeholder Documentation section (the canonical docs subdomain and public playground URL are pending DevOps; they'll fill in via find-and-replace on_coming soon_and a singlehomepagefield add perpackage.jsononce available). No code change.
0.5.0
New widget:
<goma-game-details>. Page-shaped Web Component that renders the full details for one sporting event. Header (teams, score, clock) + market-category tabs + fixed-height scrollable list of collapsible market cards with IntersectionObserver-driven batched rendering. Fills its parent; the host owns presentation context (route slot, drawer, modal, sized column, their choice). a separate change.Cross-widget cooperation works out of the box: drop a
<goma-events-horizontal>and a<goma-game-details>on the same page and card taps drive the details widget via the existinggoma:event-navigateevent (opt-out viaconfig.followEventNavigate: false).What's new
- New package
@gomagaming/game-detailspublished in lockstep alongside the existing widgets. Element:<goma-game-details>. Reference docs:docs/widgets/game-details.md. - New per-component subscription pattern (
.subscription.jsfiles) port the source post-a separate change architecture: each internal component (Header / MarketsList) owns its own WAMP subscription withonScopeDisposecleanup. Multi-instance safe: each<goma-game-details>mount gets its ownuseGameDetails()state andcreateMatchDetailsActions()closure. - Five new theme tokens with CSS-level fallbacks to existing tokens:
--goma-tabPillBackground,--goma-tabPillBackgroundActive,--goma-tabPillText,--goma-tabPillTextActive,--goma-marketCardHeaderBackground. Documented indocs/guide/theming.md → Game details. - New
marketsListHeightconfig knob (mirrored as themarkets-list-heightHTML attribute). Accepts any CSS length ('480px'default,'70vh','calc(100vh - 200px)', …): bounds the markets-list scroll viewport so the widget doesn't sprawl in hosts that don't constrain it.
@gomagaming/sports-domainadditionsactions.sports.matchDetails.*: six new WAMP actions lifted from the source repo's actions tree:subscribeToMatch/unSubscribeToMatch,subscribeToMarketGroups/unRegisterToMarketGroups,subscribeToMarketsByMarketGroupKey/unSubscribeToMarketsByMarketGroupKey. Per-instanceunregisterclosure viacreateMatchDetailsActions(): co-mounted widgets don't share WAMP handles.- New public exports:
useOddsApiActions,useActionsContainer,useLoader,useGameCardsTallOdds,RegularOdds,formattedUnixDate,formattedUnixTime,connectSportsApiReactive. These were already used internally; opening them up so external widgets (incl. this one) can compose without deep imports. adapter.createActions(router, apiContainer): adapter signature widened. Action factories close overapiContainerand readoddsApifromapiContainer.oddsApi.valuedirectly instead ofuseOddsApi()/inject(). Required because action methods are invoked from watcher callbacks that fire outside Vue's setup context (e.g., when a WAMPonInitialDumpcascades through a reactive store patch into a watcher), whereinject()returnsundefined. The closure path is reliable regardless of where the callback runs. Backwards-compatible: existing adapters that ignore the second arg keep working.useLoaderloader store gainsmatchDetailsMarketGroupMarketsflag (used by the markets-by-group action'safterInitialDumpcallback).
@gomagaming/coreadditions- Bundled i18n keys for game-details added across
en.json,fr.json, andpt.json:empty_state_match_not_available_*,empty_state_match_no_markets_*,market_categories,collapse. createWidgetElement'sCOMMON_DEFAULTSnow declare the provider / connection fields (provider,socketUrl,socketRealm,bettingApiBaseUrl,operatorId,ucsOperatorId) so every widget's reactive state bridge auto-tracks them. Setting them viael.config = { socketUrl: '…', … }propagates into the widget's_state: the newconnectSportsApiReactivehelper watches that and reconnects on runtime change.
Connection-reactive widgets (all five publishable widgets + game-details)
- Every shipped widget now uses
connectSportsApiReactiveinstead of the one-shotconnectSportsApi(...)call insetupApp. Hosts can changesocketUrl(and the other connection fields) at any time after mount and the widget tears down the old WAMP handle and re-establishes against the new params. Use case: the gomawt playground's "Apply" button for the Connection panel; any host that rotates credentials / switches environments without remounting the widget. - New public export from
@gomagaming/sports-domain:connectSportsApiReactive({ app, host, router, apiContainer, actionsContainer, name }).
gomawt playground
- New "Connection" disclosure in the TopBar with six inputs (
provider,socketUrl,socketRealm,bettingApiBaseUrl,operatorId,ucsOperatorId), Apply / Revert buttons, share-URL- localStorage persistence.
- Live connection-state badges on each widget card (driven by
goma:connectionevents). - Generic mock-fixture plumbing: fixtures can now declare arbitrary top-level keys (not just
events/sports); the Stage applies each as a JS property on the element. - Game-details added to the rail with a mock fixture (Arsenal vs Chelsea, four market groups, six markets, drifting odds + live clock).
- Events-horizontal's
subscribemode now auto-builds the WAMP topic from the manifest'ssportId+eventLimitwhen the user hasn't settopicexplicitly.
Follow-up
BetBoosttab, share button, favorite toggle, stats / lineups / H2H / commentary are explicit v2+ surfaces (recorded indocs/widgets/game-details.md → What's deferred).
- New package
Playground manifest schema additively extended (Phase 4 inspector parity with
demo/).@gomagaming/coreexports new control types (slider,textarea,json), two new tag values ('transport','mock'), an optional per-controldependsOn: { key, oneOf }field for conditional visibility, an optional top-levelthemeTokensper-widget token registry, and an optionaltopicBuilderfunction field for read-only WAMP topic previews. Phase 2 manifests continue to validate without modification (regression guard in the schema test suite).Every shipped widget's
playground.manifest.jswill be expanded in subsequent commits on this branch with the full per-widget configuration surface previously only exposed in the internaldemo/sandbox (transport block, mock-streaming knobs, full theme tokens, place-bet JSON fields where applicable). After this block the playground SPA can render the new control types and respectdependsOnfiltering; the per-widget manifests catch up in Block D.
0.4.0
Phase 2 retrofit for the betslip widgets:
@gomagaming/betslip-floatingand@gomagaming/betslip-sidebarnow ship the same./playgroundsub-export contract as the other widgets. Each has asrc/playground.manifest.jsvalidated byPlaygroundManifestSchemain@gomagaming/core, adist/playground.jsemitted alongside the main bundle (via theextraEntriesoption in@gomagaming/vite-preset), a./playgroundentry inpackage.jsonexports, and a per-package Vitest test asserting schema conformance. Unlike events-horizontal / sports-navigation-*, the betslip widgets ship nomock()fixture: they read selections from cross-widget shared storage (goma:betslip:v3) populated by events-horizontal, so the playground demos them by enabling events-horizontal alongside and tapping outcomes.The betslip widgets also join the fixed lockstep group in
.changeset/config.json, so all 7 publishable packages now version together. This fixes the version-skew introduced when betslip widgets were added at 0.2.0 in the a separate change PR without lockstep membership.The playground SPA gains all five widgets in its registry, store, Stage element map, and snippet PKG_NAMES; vite.config.ts'
optimizeDeps.includeis extended to pre-bundle all five main entries plus their./playgroundsub-exports.Betslip widgets
<goma-betslip-floating>+<goma-betslip-sidebar>ship at lockstep0.2.x, with live odds independent of<goma-events-horizontal>.@gomagaming/sports-domainNew
useBetslipSelectionsSubscriptioncomposable: each betslip widget watches its slip'sbettingOfferIds and opens its own WAMP topic subscription (/<sportsNamespace>/<ucsOperatorId>/<lang>/bettingOffers/<ids>), re-keying when the slip changes. Reconnect, locale-flip, and actions-container-ready paths all re-issue the subscribe.New
subscribeToBettingOffersin the source sportsbook's betting module: patches the per-instancebetting.bettingOfferson dump/update and per-offer-callsbetslipStore.updateBettingOfferso labels/odds refresh in the active language.New
bettingOffersTopicUrl/bettingOffersTopicSuffixhelpers intopics.js: single source of truth for the live-odds topic shape.useSharedBetslipSyncconsolidated to a singlegoma:betslip:v3envelope (replaces v2):{ selections: SelectionIdentity[], // identity-only: no odd/etc. activeBetslipId, stake, activeTab, acceptOddsChange }Live-volatile selection fields (
odd,priceUp,priceDown,isAvailable,isLive) are NEVER serialised: they live exclusively in each betslip widget's local Pinia, fed byuseBetslipOddsSync+useBetslipSelectionsSubscription. Excising volatile fields from the wire fixes intermittent betslip-row flicker when a peer widget (events-horizontal) re-broadcast a staleisAvailable/oddand fought the betslip widget's own WAMP feed.Role-gated apply/write under one key. Events-horizontal calls
useSharedBetslipSync({ subscribeToState: false }). In that mode it neither reads nor writesstake/activeTab/acceptOddsChange, and its selections-out write spreads...shared.valueso peer-written state fields survive (no lost-update of state when events-horizontal writes selections). Two echo guards (applyingSelectionsFromShared/applyingStateFromShared) keep an inbound state apply from gating concurrent local-selection writes (and vice versa).Migration. On first widget mount in any tab, runs an idempotent migration into
goma:betslip:v3from either of the older shapes:goma:betslip:v2(legacy full envelope, strips volatile on the way in) or the short-lived splitgoma:betslip-selections:v1+goma:betslip-state:v1. SeemigrateBetslipStorage.js. New schema constants andstripVolatile/stripVolatileListhelpers exported frombetslipStorageSchema.js.Deploy-window caveat: during a rollout, mixed-version tabs on the same origin do NOT share the slip: the old build keeps reading / writing its own key (v2 or v1-split) while the new build uses v3. Hard-reload after deploy to converge.
Lost-update note: simultaneous cross-tab writes to different fields of the same envelope can theoretically race (the second writer's spread is over a snapshot taken before the first's delivery). Same-tab is safe:
useSharedStoragekeeps refs in sync synchronously via BroadcastChannel +suppressBroadcast. Cross-tab the race window is sub-millisecond and the next user action re-converges. Documented, not engineered around.New shared betslip pieces:
BetslipPanel,BetslipSelectionItem,BetslipSummary,BetslipEmpty,useBetslipLogic,useBetslipOddsSync.BetslipSelectionItemrenders a dimmed disabled state whenselection.isAvailable === false.betslipPinia store getsupdateBettingOffer({ bettingOfferId, odd|odds, isAvailable, event }): splice-based for Pinia reactivity, computespriceUp/priceDown, refreshes locale-driven name fields wheneventis supplied.addSelection/upsertSelectionretained.actions.betslippromoted from a module-level singleton to a per-widget closure insidecreateActions(router): co-mounted betslip widgets no longer compete for the sameunregisterslot.actionsContainer.readyreactive ref added; the betslip composable bootstraps once both API and actions containers finish their async init.New
useBetslipSelectionsSubscriptionexport from@gomagaming/sports-domain.
@gomagaming/betslip-floating+@gomagaming/betslip-sidebar- New widgets that render the shared betslip panel: floating FAB + full-screen native
<dialog>for mobile-first layouts; always- visible<aside>for desktop layouts. Same place-bet contract (placeBetMode: 'event' | 'api'+placeBetEndpoint/placeBetHeaders/placeBetBody), same shared-storage selection sync, same per-instance WAMP subscription for live odds. - Outbound events:
goma:dialog-open/goma:dialog-close(floating only),goma:outcome-deselect,goma:bet-removed,goma:betslip-cleared,goma:stake-change,goma:place-bet-request,goma:bet-placed,goma:bet-failed,goma:retry. Listens onwindowforgoma:outcome-selectso a host can populate the slip without<goma-events-horizontal>. - Optional host-supplied formatters via
config.formatStake/config.formatOdds.
@gomagaming/events-horizontal- No longer calls
useBetslipOddsSync: its betting store feeds its own cards only; the betslip owns its own subscription. Selection ingress to the betslip stays viauseSharedBetslipSync({ subscribeToState: false }): events-horizontal writes/reads the identity-only selections channel and does NOT touch the slip-state channel (stake/tab/accept-odds-change).goma:outcome-selectevent contract unchanged.
@gomagaming/core- New i18n key
selection_unavailablein en/fr/pt for the dimmed betslip-item state.
0.3.0
Adds the playground manifest contract: every widget package now exports a
./playgroundsub-export carrying a declarative manifest (validated byPlaygroundManifestSchemain @gomagaming/core) plus an optional lazy-loaded mock fixture. Powers the new client-facing playground at /playground/ on the docs site (replaces the workspace-internal demo/ snapshot).Schema lives in @gomagaming/core; consumers can
import { validateManifest } from '@gomagaming/core'to assert manifest conformance. zod is now a runtime dep of @gomagaming/core (tree-shaken away for widget bundles that don't reference the schema).
0.2.0
Dedup architecture: externalize peers + workspace deps in widget builds, narrow
coreandsports-domainexportsto drop the./src/*escape hatch, and promote widget-consumed targets into main entries. Multi-widget consumers now resolve a single copy ofvue,pinia,vue-router,vue-i18n,@vueuse/core,autobahn-browser, swiper,@gomagaming/core, and@gomagaming/sports-domain: measured ~17% reduction in two-widget consumer bundle gzip total. Per-widget published artifacts shrink dramatically (e.g.events-horizontal1.7 MB → 6.75 KB gzip, 99.6% smaller). Sport-icon dedup remains per-widget and is tracked as a follow-up.Breaking: removes
./src/*from@gomagaming/coreand@gomagaming/sports-domainpackage.jsonexports. Any consumer that imported via@gomagaming/<pkg>/src/...paths must switch to the main-entry import (import { X } from '@gomagaming/<pkg>').sports-domain's main entry now exportsLiveRegularOdds,PreLiveRegularOdds,OutrightRegularOdds,MarketRegularOdds,SkeletonCard,useTopicSubscription,useSportsSource,sortOutcomes, andnormaliseTopicPrefix.@gomagaming/sports-navigation-dialogships for the first time at0.2.0(no prior 0.1.x publish).
0.1.1
Fix bundler dedup: each widget's Vite library build now externalizes its peerDependencies and workspace
@gomagaming/*dependencies, so the consumer's bundler resolves Vue, Pinia, autobahn, sport icons, and the shared@gomagaming/core+@gomagaming/sports-domainpackages exactly once across all installed widgets.Affected: per-widget published dist sizes drop dramatically (events-horizontal shrinks from ~6 MB to ~24 kB). Consumers installing both widgets no longer ship duplicate copies of Vue/Pinia/etc.
0.1.0
- Promote 0.1.0-rc.0 to 0.1.0 GA.
0.1.0-rc.0
Initial release.
Per-widget package layout extracted from the gomawt monorepo. See
docs/superpowers/specs/2026-04-28-monorepo-widgets-design.mdand the follow-on Plan 1 / Plan 2 / Plan 3 specs and plans for the architectural and release-engineering rationale.Public surface in 0.1.0:
<goma-events-horizontal>Web Component<goma-sports-navigation-horizontal>Web Component- Shared framework spine in
@gomagaming/core - Shared sports primitives in
@gomagaming/sports-domain
All notable changes to this package will be documented in this file. Auto-generated by Changesets; do not edit by hand.