Architecture
This page is about layering, not about any single widget: what @gomagaming/core gives every widget for free, what @gomagaming/network adds underneath for transport, what @gomagaming/sports-domain adds on top of that for sports data, what @gomagaming/ui adds on top of that for the components several widgets render, and what's left for a widget package to supply itself. If you're integrating one widget, you can skip this and go straight to its page under Widgets. Come back here when you're wiring several widgets together, choosing which peer dependencies to install once, or trying to understand why a change to a shared composable rippled across packages you didn't touch.
The five layers
Every @gomagaming/* package sits in one of five layers, and dependencies only ever point downward: a widget can depend on ui, sports-domain, network, and core; ui can depend on sports-domain and core; sports-domain can depend on network and core; network can depend on core; core depends on none of the others.
Layer 1 (@gomagaming/core): the framework spine
core has almost no idea what a "sport" or an "odds button" is: the one crack in that wall is validateConfig() in packages/core/src/createWidgetElement.js, whose advisory known-key allowlist hardcodes sports-specific config keys (socketUrl, socketRealm, ucsOperatorId, and others) that really belong to sports-domain; the source carries its own TODO admitting core "should not know about" sports-domain. Outside that one list, core's entire job is turning a Vue component into a well-behaved custom element. The concrete answer to "what does core give every widget" is the createWidgetElement() factory (packages/core/src/createWidgetElement.js): every widget's *Element.js file is a call to this one function. It hands back an HTMLElement subclass that, per instance:
- Attaches an open Shadow DOM and injects CSS via
adoptedStyleSheets(oneCSSStyleSheetbuilt once per element type and reused across instances, a<style>tag is the fallback for browsers withoutadoptedStyleSheets). - Builds a
reactive()state object (this._state) that bridges plain HTMLElement property sets to Vue reactivity: the same bridge described in Introduction. - Maps HTML attributes to that state via
attrMap(kebab-case attribute → camelCase state key + a parser function), wired throughobservedAttributes/attributeChangedCallback. - Auto-generates a JS property getter/setter for every key in
defaults: a widget author declaresdefaults: { eventId: null, marketCount: 5 }and never writesget eventId()/set eventId()by hand. Each setter is typed by the shape of its default, and a value it cannot use falls back to that default rather than being stored. For a numeric property that means a number or a numeric string is accepted (el.marketCount = 5andel.marketCount = '5'are equivalent, which matters when the value arrives from a router parameter, a CMS field or a framework that stringifies its props) whilenull,'', booleans, objects,NaNandInfinityreset the property to its default. - Creates an isolated
Pinia, a memory-history Vue Router, and avue-i18ninstance, but only in standalone mode (see below). - Installs a Vue
app.config.errorHandlerthat dispatchesgoma:error. It is the widget's last-resort net, sitting behind theonErrorCapturedboundary every shippedApp.vueinstalls. Those two divide the work cleanly, because Vue walks itserrorCapturedchain starting at the throwing component's parent: an error raised in any child component is caught by the boundary (which returnsfalse) and surfaces as the in-shadow error banner plus agoma:retryevent, never reaching this handler, while an error raised in theApp.vueroot itself has no parent to walk to, skips the boundary entirely, and lands here. It also fires for a live data connection exhausting its retry budget. See Cross-widget wiring. - Exposes
refresh(),forceError(), andclearError()as imperative methods on the element, plus aconfigsetter that validates keys (advisory only, unknown keys warn, they never block), appliesthemeandmessagesoverrides, and mirrors config onto the reactive state.
Two extension hooks are how the layers above plug in without core knowing anything about them: setupApp(app, ctx) runs once, before mount, to install extra Vue plugins (this is where a widget calls sports-domain's createApiContainer() / connectSportsApiReactive()); mapState(state, ctx) runs after mount, inside the app's reactive context, to set up watchers that translate widget state into store writes. Both can return a teardown function, which _unmount() calls before tearing down the Vue app.
core also carries theming tokens, bundled icons, locale catalogues, and a real production component, SbIcon (packages/core/src/components/SbIcon.vue) (imported at runtime by ui's odds-button and card components, see below) plus the stub modules listed in the root CLAUDE.md (iconUtils, useClientAssets, useSwiper) that let widgets import from familiar paths without pulling in the full source application those paths came from.
Each widget owns its locale
Every widget creates its own vue-i18n instance at mount and keeps it for its lifetime. Nothing about a widget's language is shared with the other widgets on the page, including the date and odds formatting helpers the widgets have in common: those receive the calling widget's i18n context directly rather than reading ambient state.
The practical consequence: you can put a French widget and a Portuguese widget side by side on one page and both render correctly, in any mount order, and either can change its locale at runtime without disturbing the other.
Layer 2 (@gomagaming/network): the transport layer
network sits between core and sports-domain. It knows nothing about sport (no markets, no odds, no topic strings, no normalized store model) and depends on core alone. Its job is opening and holding the connection every live widget eventually needs, regardless of what gets said over it once it's open:
- Connection policy: timeouts, the retry curve (
maxAttempts,baseMs,capMs), and the resume window, all resolved fromconfig.networkwith built-in defaults (resolveNetworkPolicy,backoffDelayMs). - The connection pool: one connection per
(socketUrl, socketRealm)key, refcounted so several widgets sharing a socket only open it once and only close it once every holder has released it (acquireConnection). The host page can join the same pool as an ordinary holder throughacquireSharedConnection: see The shared socket for the handle it hands back and what keeps the socket alive. - The connection container: generation stamping and a chained close-handler list, plus
release()/forceRelease()for giving a held connection back (createConnectionContainer,addCloseHandler). - The connection lifecycle: the retry ladder, resuming after the page comes back from the background, the
online/offlineand visibility/pageshowlisteners that decide when a resume is warranted, and thegoma:connectionevent a host reads to track connecting / connected / reconnecting / disconnected / failed (installConnectionLifecycle,beginConnectLadder). - REST and SSE helpers:
fetchJson,joinUrl,buildQueryString, andopenSse, afetch-based Server-Sent-Events consumer used where nativeEventSourcecan't set the request headers a session needs. - The transport registry: a
TransportProvidercontract (assertTransportProvider) plus a registry keyed onconfig.provider(registerTransport,resolveTransport), with the built-in Everymatrix WAMP transport. The autobahn connection, the handshake, the session wrapper. Undersrc/providers/everymatrix/. A host can register its own transport for a different provider before a widget ever connects.
Two constraints worth knowing if you're implementing a second transport, straight from the package's own README: the pool is keyed on (socketUrl, socketRealm). realm is WAMP vocabulary, and a transport for another protocol supplies its own identity string in that slot, widening the key being a change this package hasn't needed yet, and the close handler's return value is load-bearing, because autobahn reads it as stop_retrying; the pool returns true so a connection it has already discarded isn't silently reopened behind its back, and every handler chained on top must propagate that verdict rather than answer on autobahn's behalf. A transport for a client that never reads a close handler's return value is free to ignore this.
sports-domain's ProviderAdapter is a separate, higher-level contract: one that knows about matches, markets and topics. A provider that wants both implements one TransportProvider here and one ProviderAdapter in sports-domain; the Everymatrix provider does exactly that, with the adapter reaching down into this package's transport through resolveTransport().
Layer 3 (@gomagaming/sports-domain): sports primitives
sports-domain is where "sport" enters the picture. It depends on core and network at runtime, not just for types or build tooling, and is depended on by every widget that touches live sports data. It holds no UI: the components that render its stores live in @gomagaming/ui (Layer 4). stores/betting.js and utils/marketGroupUtils.js import core's unwrapBy helper, and the EveryMatrix API module api/everymatrix/modules/betting.js imports core's chunkBy directly, to normalize incoming WAMP records. chunkBy takes the flat array of records a WAMP push delivers and groups it by entity type (_type: match, market, outcome, betting offer, …) into separate per-entity-type arrays, which are then written into their own betting store fields. Part of the live data path, not a build-time concern. The lower-level WAMP client that also chunks records this way, api/everymatrix/client.js, now lives in @gomagaming/network (see Layer 2): sports-domain reaches it through the transport registry rather than importing it directly. Its public surface (packages/sports-domain/src/index.js) groups into:
- Pinia stores:
useBetslip,useBetting,useCms,useConfig,useLoader,useMain,useUser. These are the store shapes every widget'smapStatewrites into and every card component reads from. - The provider adapter + topic helpers:
resolveAdapter,registerAdapter,buildMatchesAggregatorTopic. This is the swappable layer between widget code and the current EveryMatrix WAMP/REST surface; widget code never imports a provider module directly. - Connection plumbing:
createApiContainer,createActionsContainer,connectSportsApi/connectSportsApiReactive,installSportsApiLifecycle. This is what a widget'ssetupApphook calls to open (and reactively reopen, onsocketUrlchange) the WAMP connection and expose typed RPC/subscription actions to the component tree. - Cross-widget shared-state composables:
useSharedStorageand the sync composables built on it (useSharedBetslipSync,useSharedFavoritesSync,useSharedSession). This is the mechanism behind the cross-widget cooperation described in each widget's page (shared betslip, shared favourites, shared session): its own guide page is coming later. - The host-fed mapper:
normalizeHostEvent, which expands one host-supplied event object into the same store slots a live feed would populate, sopropmode andsubscribemode land in the same place. - A long tail of narrower composables (
useTopicSubscription,useSportsSource,useLocationsSource,useCompetitionsSource,useEventsPagination, theuseGameCards*TallOddsfamily, betting utility functions) that individual widgets pull in as needed.
Layer 4 (@gomagaming/ui): shared UI
Every component more than one widget renders lives in one package, @gomagaming/ui, depending on sports-domain and core, with one folder per group under src/components/. Its game-card components render core's SbIcon directly, so the odds buttons every sports widget shows are literally built out of a core component.
| Folder | What it renders | Consumed by |
|---|---|---|
GameCards/, common/ | RegularOdds and its live/prelive/outright/market variants, SkeletonCard, CountryFlag | events-horizontal, competitions-list, favorites, game-details, hero-card, multi-banners, highlight-card, pros-choice, and the promo cards in this package |
Betslip/, Dialog/ | BetslipPanel and its selection, summary, system and bonus pieces; the GomaDialog modal shell and its canonical src/styles/dialog.css | betslip-floating, betslip-sidebar |
Missions/ | challenger and history cards, level rows, platform/colour tags, the empty state, the mission-detail panels | missions-hub, mission-details |
PromoCards/ | the promo card shell, hero strip, competition row, kickoff time, CTA link and skeleton | highlight-card, pros-choice |
This package renders state; it does not own it. The betting and betslip stores, the bonus registry, the missions helpers and the promo-card auto-mode resolver all stay in sports-domain, and ui reaches them through sports-domain's public entry, never by path.
Four practical consequences for anyone editing it:
- A family folder is not a public boundary. Everything is exported from one entry, so nothing stops a missions component importing a betslip one. Don't: a component two families need belongs in a shared folder (like
GameCards/orcommon/), not reached across from another family's. - Bundled consumers pay per family, CDN consumers pay for the package. A bundler tree-shakes
uilike any other ESM package, so a promo-card widget's bundle carries no betslip or missions code (checked per widget when the package was introduced). A page loading widgets over the CDN import map has no bundler and fetches the whole ofdist/ui.js(~37 kB gzip) the first time any widget that uses it mounts, then shares it with the rest. - Its Tailwind classes come from
core, like everyone's. The utility stylesheet adopted into every shadow root is compiled once, incore, whosecontentglob (../*/src/**) scans every package underpackages/, soui's classes are generated with no per-package wiring. Consuming widgets still list it in their owntailwind.config.cjscontent, next tocoreandsports-domain, to keep those lists honest about where their markup comes from. ?inlineCSS does not cross a package boundary. Every@gomagaming/*specifier is external in a published widget, so a widget cannot?inlinea stylesheet fromui. The betslip widgets keep verbatim local copies ofui'sdialog.css, and a root test (tests/shared-dialog-css-parity.test.js) keeps the copies in step.
Layer 5 (packages/<widget>): one published package per widget
Each widget package declares @gomagaming/core, and, for anything that touches sports data, @gomagaming/sports-domain, plus @gomagaming/ui whenever it renders shared UI, as a regular dependency (not a peer). No widget depends on @gomagaming/network directly; it reaches the transport layer through sports-domain's connection plumbing. A widget's *Element.js is short: it calls createWidgetElement() with a defaults/attrMap describing its public surface, a setupApp that wires the WAMP connection, and a mapState that watches its own state and patches the sports-domain stores. Everything else (the component tree, the per-widget CSS, the theming) lives under src/.
Not every widget needs layer 3. @gomagaming/casino-games, @gomagaming/competition-filter, and @gomagaming/time-filter are purely host-fed and presentational (no WAMP subscription, no RPC) so they depend on core alone. That split is a useful check on the boundary: if a widget doesn't read a Pinia store from sports-domain or open a connection through it, it has no business depending on it.
A worked example ties the layers together. packages/hero-card/src/HeroCardElement.js calls createWidgetElement from core with a setupApp that installs sports-domain's API and actions containers and calls connectSportsApiReactive, and a mapState that calls sports-domain's useBetting() store and useSharedBetslipSync() composable to patch incoming event/market/ outcome data into the shared betting store. core supplied the Shadow DOM, the reactive bridge, and the property/attribute plumbing; sports-domain supplied the store shape and the sports-specific connection wrapper, which itself calls down into network for the actual socket, retry ladder, and resume handling; HeroCardElement.js and its HeroCardApp.vue tree supplied nothing but the widget's own config shape and presentation.
Where shared UI belongs
The default answer for a new component is the widget that renders it, under its own src/components/. Move it out only when a second widget needs the same file, and then to the lowest package that both widgets already share a reason to depend on:
- One widget renders it → that widget's
src/components/. This holds even when the component is large or looks reusable:MyBetsPanelandMyBetsCardlive inmy-bets, and the betslips show them by embedding<goma-my-bets>, not by importing them. - Several widgets render it →
@gomagaming/ui: under the family's folder (Betslip/,Missions/,PromoCards/) when one family shares it, under a shared folder (GameCards/,common/) when widgets across families do. A new group gets a new folder there, not a new package. "It might be reused" is not a second widget. - It knows nothing about sport (an icon, a favourite star, a scroll lock) →
core.
sports-domain holds no UI. It is the sports data layer, and every widget that touches live data depends on it, so whatever lands there is carried by twenty-odd packages whose owners never asked for it. Its .size-limit.json is there to make that growth visible.
Why createApp() + Shadow DOM, not defineCustomElement()
Vue ships a built-in path to custom elements, defineCustomElement(), and GomaPulse doesn't use it. defineCustomElement() is designed for a single, isolated leaf component: props in, events out, no router, no store tree, no child components that need their own provide/inject context. A GomaPulse widget is not that: EventsHorizontalApp.vue renders a component tree with its own Pinia store and its own router instance. That router instance is not optional plumbing, even though widget navigation itself is mostly plain component state. game-details' tab switching, for example, is a plain ref() (useGameDetails.js's activeTab, flipped by onTabSelected), and mission-details tracks its own tab the same way with a local activeTab ref() declared directly in MissionDetailsApp.vue. Neither is a route change. The real reason a router has to exist is that shared composables call useRoute() / useRouter() unconditionally at setup: sports-domain's useGameCardsTallOdds (used by every tall-odds game card, including game-details, hero-card, and favorites) calls useRoute(), and api/everymatrix/mappers/menu.mapper.js calls useRouter(). Vue throws if either runs with no router plugin installed, regardless of whether the tree ever navigates, so createWidgetElement() has to construct an actual router (see below), not just satisfy the calls with a stub. Standing that up requires calling createApp() yourself and installing plugins on it, which is exactly what createWidgetElement() does in _mountStandalone(). defineCustomElement() gives you none of those hooks; it renders one component and stops.
Why a memory-history router
Every router core creates, createNoopRouter() for widgets with no internal navigation, createEmitRouter() for widgets that do, is built with createMemoryHistory(), never createWebHistory(). A widget is embedded inside someone else's page; it must never touch window.location, history.pushState, or the URL bar, because that URL belongs to the host app's own router, and two routers fighting over the same address bar is a bug the host would have no way to opt out of.
createEmitRouter() in packages/core/src/routers/emit-router.js makes the constraint concrete: it registers a router.beforeEach guard that intercepts every navigation attempt, dispatches a goma:navigate custom event carrying the intended route (name, params, path, query), and then calls next(false). Cancelling the navigation inside the widget outright. The host decides what "navigating" means (open a modal, change its own route, do nothing); the widget's own router state never advances past the attempt. createNoopRouter() is the same idea with nothing to intercept: a single catch-all route so components that call useRouter() don't throw, with no navigation ever attempted.
Peer dependencies
Vue, Pinia, vue-router, vue-i18n, and @vueuse/core are declared as peerDependencies on every widget package (some widgets add provider-specific peers too, hero-card's package.json also lists autobahn-browser and swiper). autobahn-browser is a peer of @gomagaming/network now, not sports-domain: it moved with the Everymatrix transport, and a consumer installs it the same way as before. The consumer installs these once, at their application's top level, and every @gomagaming/* package resolves to that same install. This isn't just tidiness: Pinia and vue-router both rely on module-level singletons internally, so if two different copies of the same library ended up in a host's final bundle (one bundled into @gomagaming/events-horizontal, another into @gomagaming/hero-card) provide/inject and store identity would break between them, and you'd see 23 widgets that could not agree with each other about anything. A bundler's dependency deduplication only works across packages that all point at the same peer version range, which is why the peer range needs to stay in sync across every widget package.
@gomagaming/core, @gomagaming/network, and @gomagaming/sports-domain, by contrast, are regular dependencies, not peers: network is a dependency of sports-domain, never installed directly by a widget or a consumer. There's nothing for a consumer to substitute: every widget in a given GomaPulse release is built against one exact version of each, published in lockstep, so the package manager's ordinary deduplication (one node_modules/@gomagaming/core shared by every widget that asks for the same version range) is enough.
Dependency table
Generated directly from every package's package.json, filtered to @gomagaming/* dependencies:
bash
for p in packages/*/package.json; do
node -e "const j=require('./$p');const d=Object.keys(j.dependencies||{}).filter(k=>k.startsWith('@gomagaming'));if(j.name.startsWith('@gomagaming'))console.log(j.name.padEnd(42), d.join(', ')||'-')"
done| Package | @gomagaming/* dependencies |
|---|---|
@gomagaming/core | - |
@gomagaming/network | @gomagaming/core |
@gomagaming/sports-domain | @gomagaming/core, @gomagaming/network |
@gomagaming/ui | @gomagaming/core, @gomagaming/sports-domain |
@gomagaming/betslip-floating | @gomagaming/core, @gomagaming/my-bets, @gomagaming/sports-domain, @gomagaming/ui |
@gomagaming/betslip-sidebar | @gomagaming/core, @gomagaming/my-bets, @gomagaming/sports-domain, @gomagaming/ui |
@gomagaming/casino-games | @gomagaming/core |
@gomagaming/competition-filter | @gomagaming/core |
@gomagaming/competitions-list | @gomagaming/core, @gomagaming/sports-domain, @gomagaming/ui |
@gomagaming/events-horizontal | @gomagaming/core, @gomagaming/sports-domain, @gomagaming/ui |
@gomagaming/favorites | @gomagaming/core, @gomagaming/sports-domain, @gomagaming/ui |
@gomagaming/filters-settings | @gomagaming/core, @gomagaming/sports-domain |
@gomagaming/game-details | @gomagaming/core, @gomagaming/sports-domain, @gomagaming/ui |
@gomagaming/hero-card | @gomagaming/core, @gomagaming/sports-domain, @gomagaming/ui |
@gomagaming/highlight-card | @gomagaming/core, @gomagaming/sports-domain, @gomagaming/ui |
@gomagaming/live-chat | @gomagaming/my-bets |
@gomagaming/messenger | @gomagaming/my-bets |
@gomagaming/mission-details | @gomagaming/core, @gomagaming/sports-domain, @gomagaming/ui |
@gomagaming/missions-hub | @gomagaming/core, @gomagaming/sports-domain, @gomagaming/ui |
@gomagaming/multi-banners | @gomagaming/core, @gomagaming/sports-domain, @gomagaming/ui |
@gomagaming/my-bets | @gomagaming/core, @gomagaming/sports-domain |
@gomagaming/pros-choice | @gomagaming/core, @gomagaming/sports-domain, @gomagaming/ui |
@gomagaming/region-filter | @gomagaming/core, @gomagaming/sports-domain |
@gomagaming/search | @gomagaming/core, @gomagaming/sports-domain |
@gomagaming/sports-navigation-dialog | @gomagaming/core, @gomagaming/sports-domain |
@gomagaming/sports-navigation-horizontal | @gomagaming/core, @gomagaming/sports-domain |
@gomagaming/suggested-bet | @gomagaming/core, @gomagaming/sports-domain |
@gomagaming/time-filter | @gomagaming/core |
@gomagaming/top-competitions | @gomagaming/core, @gomagaming/sports-domain |
@gomagaming/top-competitions-pills | @gomagaming/core, @gomagaming/sports-domain |
(@gomagaming/vite-preset also lives under packages/ but is a build-time-only Vite/Tailwind/PostCSS config shared across packages, it ships to no widget's runtime bundle and is left out of the table above.)
Notice betslip-floating, betslip-sidebar, live-chat and messenger are the four packages that reach sideways into another widget package (@gomagaming/my-bets) rather than only downward into sports-domain: each embeds <goma-my-bets> inside its own surface (a tab in the two betslips, the bet picker on the two chat widgets' share screens), so they depend on the published my-bets package the same way a widget depends on sports-domain.
The two chat packages are the odd ones out in a second way: they are the only widgets with no direct @gomagaming/core or @gomagaming/sports-domain dependency. They were imported from standalone repos, and they reach core and sports-domain only transitively, through my-bets: neither composes the core spine described above the way a sports-domain widget does. They no longer differ from the rest of the table in how they get their Vue: both externalise vue, pinia and vue-i18n as peerDependencies, so a page running several widgets loads one copy of the framework rather than one per widget.
They do differ from each other in when my-bets loads. @gomagaming/live-chat imports its share screen statically, so my-bets is in the entry bundle. @gomagaming/messenger loads its share screen lazily, behind ticket sharing being enabled and the user opening it, so my-bets sits in a separate chunk. Either way both packages declare the same five peers, because my-bets reaches core and sports-domain and needs vue-router and @vueuse/core: packages neither chat widget imports itself.
Layering diagram
host page
│
┌────────────────────┼────────────────────┐
│ │ │
<goma-events-…> <goma-betslip-…> <goma-hero-card> ← one package each
│ │ │
│ @gomagaming/ui │ ← shared UI
│ │ │ (game cards, betslip, missions, promo cards)
└────────────────────┼────────────────────┘
│
@gomagaming/sports-domain ← stores, feed, composables
│
@gomagaming/network ← transport, socket pool, reconnect
│
@gomagaming/core ← element factory, theming, i18n
│
Vue · Pinia · vue-router · vue-i18n ← peer deps, one copy<goma-casino-games>, <goma-competition-filter>, and <goma-time-filter> are the exception in this picture: they sit directly on @gomagaming/core, skipping the sports-domain row entirely, because they're host-fed and never touch a store or a WAMP topic.
What isolation guarantees you get
Because every widget goes through the same createWidgetElement() factory, a host embedding several GomaPulse widgets on one page gets the same guarantees regardless of which widgets they are:
- Separate Pinia per instance.
_mountStandalone()callscreatePinia()fresh for every element instance: two<goma-events-horizontal>elements on the same page, or the same widget mounted twice, never share store state or collide on a store key. - Memory-history router. No URL side effects. Covered above: no widget instance ever reads or writes
window.location. - Its own i18n instance and locale.
createI18n()runs fresh for every element instance, the same way Pinia does: one widget'sconfig.localenever bleeds into a sibling's. Two widgets on the same page can each render in a different language, and shared formatting helpers (kickoff dates, live-status labels) take the caller's{ t, locale }explicitly rather than reading it from anywhere ambient. - CSS confined to the shadow root. Styles are injected via
adoptedStyleSheets(or a scoped<style>fallback) inside each element's own Shadow DOM: a widget's Tailwind utility classes can never leak out and collide with the host page's own class names, and the host page's CSS can't reach in and override widget internals unintentionally. - Events cross the boundary deliberately, via
composed: true. Nothing about a widget's internal state is observable from outside it except through theCustomEvents it explicitly dispatches: every one of them built withbubbles: true, composed: true, which is what lets agoma:outcome-selectfired deep inside a Shadow DOM reach a listener the host attached onwindowor on the element itself.
The trade-off worth naming: none of this is a security sandbox. Shadow DOM here is about style and structure isolation, not script isolation: a widget's JavaScript runs with the same privileges as the host page's. The guarantee is that two independently-authored widgets on the same page won't accidentally step on each other's CSS or Pinia state, not that a widget is untrusted code you can safely embed without vetting it.
What to read next
- Introduction: the Shadow DOM anatomy, the reactive bridge, and the standalone/hosted mode split in more detail.
- Theming: the
--goma-*token reference every widget in every layer above paints with. - Widget reference: the per-widget config, props, and event contract for the twenty-three sports widgets, plus the two chat widgets, which follow their own conventions.
Cross-widget wiring (the goma:* event catalogue and follow* opt-in props) and the shared-state storage contracts (goma:betslip:v4, goma:favorites:v1, goma:session:v1) get their own dedicated guide pages: this page stops at the package-layering question.