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.
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.
Install
@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 swiper vue-toastificationvue, pinia, vue-router, vue-i18n, @vueuse/core, autobahn-browser, swiper, and vue-toastification are declared as peerDependencies so a consumer with multiple @gomagaming/* widgets gets exactly one copy of each.
Element
html
<goma-events-horizontal id="sb"></goma-events-horizontal>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. |
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). 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), retry: { maxAttempts: 5, baseMs: 1000, capMs: 30000 }. Exponential backoff is baseMs * 2^(attempt-1), capped at capMs. |
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, and per-attempt connect failures. Production-fatal errors (Vue render errors, terminal failure after retry budget exhausted, invalid attribute values) log unconditionally. Defaults to false. |
Data sources (choose one)
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'Direct events — bypass the socket and feed events as a plain array:
js
el.events = [{ id: '123', homeShortParticipantName: 'PAC', /* ... */ }]Props (HTML attributes + JS properties)
| Property | Attribute | Default | Description |
|---|---|---|---|
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. |
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 | Cross-widget automation — listens at the window for any <goma-sports-navigation-horizontal>'s goma:sport-select events and rewrites the sportId segment of the current topic. The internal subscription rebinds transparently — no reconnect. No-op when topic is empty (direct-events mode). Set to false to opt out. See Following the sport picker. |
sportPickerSelector | sport-picker-selector | '' | Optional CSS selector that scopes followSportPicker to a specific picker (e.g. '#main-picker', '[data-feed="primary"]'). Empty = match any picker. |
followRegionPicker | follow-region-picker | true | Sister automation for <goma-region-filter> — listens at the window for goma:region-select events and rewrites the locationId segment of the current topic. The "All" sentinel (regionId: 'all') maps to the matches-aggregator's literal all no-filter segment. Set to false to opt out. See Following the region picker. |
regionPickerSelector | region-picker-selector | '' | Optional CSS selector that scopes followRegionPicker to a specific region filter. Empty = match any. |
followCompetitionPicker | follow-competition-picker | true | Sister automation for <goma-competition-filter> — listens at the window for goma:competition-select events and rewrites the tournamentId segment of the current topic. The "All" sentinel maps to the matches-aggregator's literal all no-filter segment. Set to false to opt out. |
competitionPickerSelector | competition-picker-selector | '' | Optional CSS selector that scopes followCompetitionPicker to a specific competition filter. Empty = match any. |
followTimePicker | follow-time-picker | true | Sister automation for <goma-time-filter> — listens at the window for goma:time-select events and rewrites the hoursInterval segment of the current topic. Set to false to opt out. |
timePickerSelector | time-picker-selector | '' | Optional CSS selector that scopes followTimePicker to a specific time filter. 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 = feed order. Pairs with <goma-filters-settings>' 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. |
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.
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 the existing --goma-highlightSecondary (border + divider) and --goma-textSecondary (label) tokens — 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 |
Loading & empty states
The widget shows three visual states while waiting for data:
Loading — skeleton placeholder cards (pulsing blocks mimicking the card layout) are shown from mount until the first batch of events arrives. The number of skeletons is configurable via
skeletonCount(default3).Empty — if the API connects but returns zero events for the topic, a centred "No events available" message is shown. The message is localised (en, fr, pt).
Error — if a render error occurs, a themed error banner is shown for 8 seconds with a dismiss button. Connection errors emit
goma:errorto the host.
Events
See Custom events (v1 contract) below for the full event reference with typed detail schemas.
Listen with standard DOM APIs:
js
el.addEventListener('goma:outcome-select', (e) => {
console.log('Added to betslip:', e.detail)
})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>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 | {count} markets | Market count on see-all card |
add_favorite | Add to favorites | Favorite star aria-label |
remove_favorite | Remove from favorites | Favorite star aria-label (active) |
loading | Loading | Skeleton loading state |
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' } } }Custom events (v1 contract)
The following events are the v1 public API. Their detail shapes will not change without a major version bump. All events use bubbles: true and composed: true to cross the Shadow DOM boundary.
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 } | Render or data error. |
| Canonical | goma:outcome-select | { bettingOfferId: string, outcomeName: string, odds: number, eventId: string, marketId: string } | User taps odds to add to betslip. |
| Canonical | goma:outcome-deselect | { bettingOfferId: string } | User taps selected odds to remove. |
| Canonical | goma:event-navigate | { eventId: string, homeTeam: string, awayTeam: string, competitionName: string } | User clicks card to navigate. |
| Canonical | goma:favorite-toggle | { eventId: string, isFavorite: boolean } | User toggles favorite star. |
| Alias | error | Same as goma:error | Dispatched in parallel with goma:error. Prefer the namespaced form. |
| Alias | goma:connected | {} | Dispatched once when the API connection is first established. Prefer goma:connection with state === 'connected'. |
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.
querySelector type safety
ts
const el = document.querySelector('goma-events-horizontal')!
el.theme = { highlightPrimary: '#dc2626' } // autocomplete for token names
el.config = { apiBaseUrl: '...' } // typed config shapeTyped event listeners
ts
import type { GomaCustomEvent } from '@gomagaming/core'
el.addEventListener('goma:outcome-select', (e: GomaCustomEvent<'goma:outcome-select'>) => {
console.log(e.detail.bettingOfferId) // string
})Vue GlobalComponents
@gomagaming/core augments Vue's GlobalComponents interface, so <goma-events-horizontal> is recognised in Vue templates with full type checking:
vue
<template>
<goma-events-horizontal ref="el" />
</template>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).
goma:connection event detail
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)
}Handling connection state in the host
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
}
})Subscription replay
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.
Following the sport picker
Set followSportPicker = true to wire this widget to any<goma-sports-navigation-horizontal> on the page without any host-side glue.
js
const events = document.getElementById('feed')
events.followSportPicker = true
events.topic = 'custom-matches-aggregator/3/all/all/all/POPULAR/LIVE/10/5'When the user taps Tennis (id=3) in the picker, the picker dispatches goma:sport-select { sportId: 3, … } (bubbles + composed). This widget's window-level listener catches it and rewrites the topic in place:
custom-matches-aggregator/3/all/all/all/POPULAR/LIVE/10/5
^ sportId segment swapped to whatever the user pickedThe widget's internal useTopicSubscription watcher detects state.topic changing and rebinds the WAMP subscription transparently. No reconnect, no recreate, no host code.
The window listener attaches and detaches reactively with the followSportPicker prop — flip it to false at runtime and the listener is removed; flip it back on, the listener is re-attached. It's also removed on unmount.
Multiple events-horizontal instances on the same page each listen independently and stay in sync. Multiple pickers — each subsequent selection wins, unless you scope to a specific picker with sportPickerSelector:
html
<goma-sports-navigation-horizontal id="main-picker"></goma-sports-navigation-horizontal>
<goma-sports-navigation-horizontal id="featured"></goma-sports-navigation-horizontal>
<goma-events-horizontal
follow-sport-picker
sport-picker-selector="#main-picker">
</goma-events-horizontal>The events-horizontal will only react to selections from the picker whose host element matches the selector. Any selector valid in Element.matches() works:
| Selector | Matches |
|---|---|
'#main-picker' | Picker with id="main-picker" |
'[data-feed="primary"]' | Picker with data-feed="primary" |
'goma-sports-navigation-horizontal.featured' | Pickers carrying class="featured" |
The selector is matched against event.target after the event has been retargeted across the shadow boundary (composed: true), so it always sees the custom-element host — never internal Vue components.
The automation is a no-op when:
topicis empty (direct-events mode — there's no topic to rewrite).- The current topic doesn't start with
custom-matches-aggregator/(unknown format — left untouched). followSportPickerisfalse(default istrue— set tofalseto opt out).
When debug is on, the topic rewrite is logged: [goma-events-horizontal] follow-sport-picker → topic: ….
Following the region picker
Set followRegionPicker = true to wire this widget to any<goma-region-filter> on the page. Same one-line setup as the sport picker — no host-side glue required.
js
const events = document.getElementById('feed')
events.followRegionPicker = true
events.topic = 'custom-matches-aggregator/3/all/all/all/POPULAR/LIVE/10/5'When the user taps a region (say, Netherlands id=19) in the filter, the filter dispatches goma:region-select { regionId: 19, … }. This widget's window-level listener catches it and rewrites the locationId segment (index 1) of the topic in place:
custom-matches-aggregator/3/19/all/all/POPULAR/LIVE/10/5
^ locationId segment swapped to whatever the user pickedWhen the user taps the hardcoded "All" pill, regionId: 'all' lands in the same segment — which matches the matches-aggregator's literal all no-filter value:
custom-matches-aggregator/3/all/all/all/POPULAR/LIVE/10/5
^ back to the no-filter sentinelThe same listener semantics apply as followSportPicker: reactive attach/detach via the boolean prop, multi-instance safe, optional regionPickerSelector for scoping to a specific filter, no-op when topic is empty or doesn't start with custom-matches-aggregator/.
When debug is on, the rewrite is logged: [goma-events-horizontal] follow-region-picker → topic: ….
Following the competition picker
Set followCompetitionPicker = true to wire this widget to any<goma-competition-filter> on the page — same one-line setup as the other two picker hooks. Listens at the window for goma:competition-select and rewrites segment 2 (tournamentId) of the matches-aggregator topic in place:
js
const events = document.getElementById('feed')
events.followCompetitionPicker = true
events.topic = 'custom-matches-aggregator/3/all/all/all/POPULAR/LIVE/10/5'
// ^ tournamentId segment swapped when a competition pill is tappedThe "All" pill emits competitionId: 'all' which lines up with the topic's literal all no-filter sentinel. Same reactive attach/detach via the boolean prop, no-op when topic is empty, optional competitionPickerSelector for multi-instance scoping.
When debug is on, the rewrite is logged: [goma-events-horizontal] follow-competition-picker → topic: ….
Following the time picker
Set followTimePicker = true to wire this widget to any <goma-time-filter> on the page. Same one-line setup as the other picker hooks. Listens at the window for goma:time-select and rewrites segment 3 (hoursInterval) of the matches-aggregator topic in place using detail.timeValue:
js
const events = document.getElementById('feed')
events.followTimePicker = true
events.topic = 'custom-matches-aggregator/3/all/all/all/POPULAR/LIVE/10/5'
// ^ hoursInterval segment (index 3)When the user taps the "Today" pill (which the host fed as { label: 'Today', value: '0-24' }), the topic becomes custom-matches-aggregator/3/all/all/0-24/POPULAR/LIVE/10/5. The hardcoded "All" pill emits timeValue: 'all' and lines up with the topic's literal all no-filter segment.
Same reactive attach/detach via the boolean prop, no-op when topic is empty or doesn't start with custom-matches-aggregator/, optional timePickerSelector for multi-instance scoping. When debug is on: [goma-events-horizontal] follow-time-picker → topic: ….
Combining all four pickers
followSportPicker, followRegionPicker, followCompetitionPicker, and followTimePicker are independent — enable any combination to wire one events-horizontal feed up to a full picker chain. Each segment is rewritten when its respective picker fires:
html
<goma-sports-navigation-horizontal id="sports"></goma-sports-navigation-horizontal>
<goma-region-filter id="regions" follow-sport-picker></goma-region-filter>
<goma-competition-filter
id="comps"
follow-sport-picker
follow-region-picker
></goma-competition-filter>
<goma-time-filter id="times"></goma-time-filter>
<goma-events-horizontal
id="feed"
follow-sport-picker
follow-region-picker
follow-competition-picker
follow-time-picker
topic="custom-matches-aggregator/3/all/all/all/POPULAR/LIVE/10/5"
></goma-events-horizontal>Selection chain: tap a sport → region filter refetches, competition filter refetches, events feed rewrites sportId. Tap a region → competition filter client-side filters, events feed rewrites locationId. Tap a competition → events feed rewrites tournamentId. Tap a time → events feed rewrites hoursInterval. Picker resets propagate: a sport change resets region-filter and competition-filter back to "All" (and emits the cleared events so the events feed clears its segments in the same tick); the time-filter is orthogonal so its selection survives. One subscription throughout; no reconnects.
Cross-widget integration with the betslip
When a betslip widget (<goma-betslip-floating> or <goma-betslip-sidebar>) is on the same page (or another tab on the same origin), this widget shares its selections with it via a single goma:betslip:v3 envelope in localStorage + BroadcastChannel:
- Selections (identity + last-known live state) —
outcomeId,bettingOfferId,eventId/eventName,marketId/marketName,outcomeName, etc. PLUSodd,isAvailable,isLive(last-known values, refreshed by each betslip widget's own WAMP feed on mount). Pure UI flash flags (priceUp,priceDown) are NEVER serialised — components derive their own up/down indicator from a local watch onselection.odd. The original cross-widget odds flicker is prevented byupdates.jsno longer pushing staleisAvailablefrom this widget's update pipeline (Fix #1) — leaving each betslip widget's WAMP feed as the single authoritative writer for its own live fields. - Slip-level state —
activeBetslipId,stake,activeTab,acceptOddsChange. Events-horizontal callsuseSharedBetslipSync({ subscribeToState: false }): it neither reads nor writes these fields (its UI doesn't render them), and when it writes selections it spreads...shared.valueso peer-written state fields survive.
A one-shot migration runs on first widget mount in any tab; older goma:betslip:v2 entries and the short-lived goma:betslip-selections:v1 + goma:betslip-state:v1 split are merged into v3 (transient flash flags stripped on the way in; identity + last-known live state preserved) and the legacy entries deleted.
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.
Sequence diagram
mermaid
sequenceDiagram
participant Host
participant Widget
participant BetslipStore
Host->>Widget: el.selectedOutcomes = ['offer-1']
Widget->>BetslipStore: diff → addSelection('offer-1')
Note over Widget: UI highlights offer-1
Note over Host: User clicks offer-2 inside widget
Widget->>BetslipStore: addSelection('offer-2')
Widget->>Host: goma:outcome-select { bettingOfferId: 'offer-2' }
Host->>Widget: el.selectedOutcomes = ['offer-1', 'offer-2']
Note over Widget: No-op (already in sync)
Host->>Widget: el.selectedOutcomes = []
Widget->>BetslipStore: diff → removeSelection('offer-1'), removeSelection('offer-2')
Note over Widget: All highlights clearedDedup guard
Rapid double-clicks on the same odds button are deduplicated — only one goma:outcome-select or goma:outcome-deselect event fires per animation frame.
Performance
Stress test
A Playwright spec at tests/perf/widget-stress.spec.ts automates the frame-budget regression check that the retired demo/perf-stress-test.html covered manually. 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 that the retired demo/soak-test.html covered manually. 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. 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.
Source layout
| Layer | File |
|---|---|
| Element class | packages/events-horizontal/src/EventsHorizontalElement.js |
| App wrapper | packages/events-horizontal/src/EventsHorizontalApp.vue |
| Component tree | packages/events-horizontal/src/components/EventsLists/* |
| Entry / registration | packages/events-horizontal/src/index.js |
| Manifest | packages/events-horizontal/custom-elements.json |
Shared widget building blocks live in @gomagaming/sports-domain:
| Layer | File |
|---|---|
| Game cards | packages/sports-domain/src/components/GameCards/* |
| Card composables | packages/sports-domain/src/composables/games/gamecards/useGameCardsTallOdds.js |
| Topic subscription | packages/sports-domain/src/composables/ui/useTopicSubscription.js |
| Pinia stores | packages/sports-domain/src/stores/{betting,betslip}.js |
| Wire-format mapper | packages/sports-domain/src/mappers/storeMapper.js |
| API container | packages/sports-domain/src/plugins/api/Api.js |
Framework spine pieces live in @gomagaming/core:
| Layer | File |
|---|---|
| Element factory | packages/core/src/createWidgetElement.js |
| Emit router | packages/core/src/routers/emit-router.js |
| Shared type declarations | packages/core/src/types/index.d.ts |